···16open PDSharp.Core.Repository
17open PDSharp.Core.Mst
18open PDSharp.Core.Crypto
01920module App =
21 /// Repo state per DID: MST root, collections, current rev, head commit CID
···39 let blockStore = MemoryBlockStore()
40 let mutable signingKeys : Map<string, EcKeyPair> = Map.empty
410000000000000000000000042 let getOrCreateKey (did : string) =
43 match Map.tryFind did signingKeys with
44 | Some k -> k
···155 }
156157 repos <- Map.add did updatedRepo repos
00000158159 let uri = $"at://{did}/{request.collection}/{rkey}"
160 ctx.SetStatusCode 200
···304305 repos <- Map.add did updatedRepo repos
30600000307 ctx.SetStatusCode 200
308309 return!
···496 return! ctx.WriteBytesAsync data
497 }
4980000000000000000000000000000000000000000000000000499 let webApp =
500 choose [
501 GET
···507 GET >=> route "/xrpc/com.atproto.sync.getRepo" >=> getRepoHandler
508 GET >=> route "/xrpc/com.atproto.sync.getBlocks" >=> getBlocksHandler
509 GET >=> route "/xrpc/com.atproto.sync.getBlob" >=> getBlobHandler
0510 route "/" >=> text "PDSharp PDS is running."
511 RequestErrors.NOT_FOUND "Not Found"
512 ]
513514- let configureApp (app : IApplicationBuilder) = app.UseGiraffe webApp
00515516 let configureServices (config : AppConfig) (services : IServiceCollection) =
517 services.AddGiraffe() |> ignore
···16open PDSharp.Core.Repository
17open PDSharp.Core.Mst
18open PDSharp.Core.Crypto
19+open PDSharp.Core.Firehose
2021module App =
22 /// Repo state per DID: MST root, collections, current rev, head commit CID
···40 let blockStore = MemoryBlockStore()
41 let mutable signingKeys : Map<string, EcKeyPair> = Map.empty
4243+ // Firehose subscriber management
44+ open System.Net.WebSockets
45+ open System.Collections.Concurrent
46+47+ /// Connected WebSocket subscribers
48+ let subscribers = ConcurrentDictionary<Guid, WebSocket>()
49+50+ /// Broadcast a commit event to all connected subscribers
51+ let broadcastEvent (event : CommitEvent) =
52+ let eventBytes = encodeEvent event
53+ let segment = ArraySegment<byte>(eventBytes)
54+55+ for kvp in subscribers do
56+ let ws = kvp.Value
57+58+ if ws.State = WebSocketState.Open then
59+ try
60+ ws.SendAsync(segment, WebSocketMessageType.Binary, true, Threading.CancellationToken.None)
61+ |> Async.AwaitTask
62+ |> Async.RunSynchronously
63+ with _ ->
64+ subscribers.TryRemove(kvp.Key) |> ignore
65+66 let getOrCreateKey (did : string) =
67 match Map.tryFind did signingKeys with
68 | Some k -> k
···179 }
180181 repos <- Map.add did updatedRepo repos
182+183+ let! allBlocks = (blockStore :> IBlockStore).GetAllCidsAndData()
184+ let carBytes = Car.createCar [ commitCid ] allBlocks
185+ let event = createCommitEvent did newRev commitCid carBytes
186+ broadcastEvent event
187188 let uri = $"at://{did}/{request.collection}/{rkey}"
189 ctx.SetStatusCode 200
···333334 repos <- Map.add did updatedRepo repos
335336+ let! allBlocks = (blockStore :> IBlockStore).GetAllCidsAndData()
337+ let carBytes = Car.createCar [ commitCid ] allBlocks
338+ let event = createCommitEvent did newRev commitCid carBytes
339+ broadcastEvent event
340+341 ctx.SetStatusCode 200
342343 return!
···530 return! ctx.WriteBytesAsync data
531 }
532533+ /// subscribeRepos: WebSocket firehose endpoint
534+ let subscribeReposHandler : HttpHandler =
535+ fun next ctx -> task {
536+ if ctx.WebSockets.IsWebSocketRequest then
537+ let cursor =
538+ match ctx.Request.Query.TryGetValue("cursor") with
539+ | true, v when not (String.IsNullOrWhiteSpace(v.ToString())) ->
540+ Int64.TryParse(v.ToString())
541+ |> function
542+ | true, n -> Some n
543+ | _ -> None
544+ | _ -> None
545+546+ let! webSocket = ctx.WebSockets.AcceptWebSocketAsync()
547+ let id = Guid.NewGuid()
548+ subscribers.TryAdd(id, webSocket) |> ignore
549+550+ let buffer = Array.zeroCreate<byte> 1024
551+552+ try
553+ let mutable loop = true
554+555+ while loop && webSocket.State = WebSocketState.Open do
556+ let! result = webSocket.ReceiveAsync(ArraySegment(buffer), Threading.CancellationToken.None)
557+558+ if result.MessageType = WebSocketMessageType.Close then
559+ loop <- false
560+ finally
561+ subscribers.TryRemove(id) |> ignore
562+563+ if webSocket.State = WebSocketState.Open then
564+ webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", Threading.CancellationToken.None)
565+ |> Async.AwaitTask
566+ |> Async.RunSynchronously
567+568+ return Some ctx
569+ else
570+ ctx.SetStatusCode 400
571+572+ return!
573+ json
574+ {
575+ error = "InvalidRequest"
576+ message = "WebSocket upgrade required"
577+ }
578+ next
579+ ctx
580+ }
581+582 let webApp =
583 choose [
584 GET
···590 GET >=> route "/xrpc/com.atproto.sync.getRepo" >=> getRepoHandler
591 GET >=> route "/xrpc/com.atproto.sync.getBlocks" >=> getBlocksHandler
592 GET >=> route "/xrpc/com.atproto.sync.getBlob" >=> getBlobHandler
593+ GET >=> route "/xrpc/com.atproto.sync.subscribeRepos" >=> subscribeReposHandler
594 route "/" >=> text "PDSharp PDS is running."
595 RequestErrors.NOT_FOUND "Not Found"
596 ]
597598+ let configureApp (app : IApplicationBuilder) =
599+ app.UseWebSockets() |> ignore
600+ app.UseGiraffe webApp
601602 let configureServices (config : AppConfig) (services : IServiceCollection) =
603 services.AddGiraffe() |> ignore
+66-2
README.md
···3435The server will start at `http://localhost:5000`.
3637-### Verify
3839-Check the `describeServer` endpoint:
4041```bash
42curl http://localhost:5000/xrpc/com.atproto.server.describeServer
000000000000000000000000000000000000000000000000000000000000000043```
4445## Configuration
···3435The server will start at `http://localhost:5000`.
3637+## API Testing
3839+### Server Info
4041```bash
42curl http://localhost:5000/xrpc/com.atproto.server.describeServer
43+```
44+45+### Record Operations
46+47+**Create a record:**
48+49+```bash
50+curl -X POST http://localhost:5000/xrpc/com.atproto.repo.createRecord \
51+ -H "Content-Type: application/json" \
52+ -d '{"repo":"did:web:test","collection":"app.bsky.feed.post","record":{"text":"Hello, ATProto!"}}'
53+```
54+55+**Get a record** (use the rkey from createRecord response):
56+57+```bash
58+curl "http://localhost:5000/xrpc/com.atproto.repo.getRecord?repo=did:web:test&collection=app.bsky.feed.post&rkey=<RKEY>"
59+```
60+61+**Put a record** (upsert with explicit rkey):
62+63+```bash
64+curl -X POST http://localhost:5000/xrpc/com.atproto.repo.putRecord \
65+ -H "Content-Type: application/json" \
66+ -d '{"repo":"did:web:test","collection":"app.bsky.feed.post","rkey":"my-post","record":{"text":"Updated!"}}'
67+```
68+69+### Sync & CAR Export
70+71+**Get entire repository as CAR:**
72+73+```bash
74+curl "http://localhost:5000/xrpc/com.atproto.sync.getRepo?did=did:web:test" -o repo.car
75+```
76+77+**Get specific blocks** (comma-separated CIDs):
78+79+```bash
80+curl "http://localhost:5000/xrpc/com.atproto.sync.getBlocks?did=did:web:test&cids=<CID1>,<CID2>" -o blocks.car
81+```
82+83+**Get a blob by CID:**
84+85+```bash
86+curl "http://localhost:5000/xrpc/com.atproto.sync.getBlob?did=did:web:test&cid=<BLOB_CID>"
87+```
88+89+### Firehose (WebSocket)
90+91+Subscribe to real-time commit events using [websocat](https://github.com/vi/websocat):
92+93+```bash
94+# Install websocat (macOS)
95+brew install websocat
96+97+# Connect to firehose
98+websocat ws://localhost:5000/xrpc/com.atproto.sync.subscribeRepos
99+```
100+101+Then create/update records in another terminal to see CBOR-encoded commit events stream in real-time.
102+103+**With cursor for resumption:**
104+105+```bash
106+websocat "ws://localhost:5000/xrpc/com.atproto.sync.subscribeRepos?cursor=5"
107```
108109## Configuration