An in browser local PDS localpds.at
20

Configure Feed

Select the types of activity you want to include in your feed.

make changes to make fe and be talk more effective

authored by

Niall Bunting and committed by
Niall Bunting
(May 31, 2026, 1:14 AM +0100) 33a359bc 94077610

+296 -344
+1 -1
backend/firehose.go
··· 83 83 //} 84 84 85 85 hub.add(conn) 86 - log.Println("firehose subscriber connected: %s (total: %d)", c.GetHeader("User-Agent"), len(hub.clients)) 86 + log.Printf("firehose subscriber connected: %s (total: %d)", c.GetHeader("User-Agent"), len(hub.clients)) 87 87 88 88 defer func() { 89 89 hub.remove(conn)
+9 -1
backend/main.go
··· 13 13 14 14 // Sync crawl endpoints — relay crawler hits these with ?did=x 15 15 r.GET("/xrpc/com.atproto.sync.getRepo", getDidThenHandleSyncXRPC) 16 - r.GET("/xrpc/com.atproto.sync.describeRepo", getDidThenHandleSyncXRPC) 17 16 r.GET("/xrpc/com.atproto.sync.getLatestCommit", getDidThenHandleSyncXRPC) 18 17 r.GET("/xrpc/com.atproto.sync.getHead", getDidThenHandleSyncXRPC) 19 18 r.GET("/xrpc/com.atproto.sync.listBlobs", getDidThenHandleSyncXRPC) 20 19 r.POST("/xrpc/com.atproto.sync.notifyOfUpdate", func(c *gin.Context) { c.Status(200) }) 20 + 21 + r.GET("/xrpc/com.atproto.repo.describeRepo", getDidThenHandleSyncXRPC) 22 + 23 + r.GET("/xrpc/com.atproto.sync.getBlob", getDidThenHandleSyncXRPC) 24 + r.GET("/xrpc/com.atproto.sync.getBlocks", getDidThenHandleSyncXRPC) 25 + r.GET("/xrpc/com.atproto.sync.getRecord", getDidThenHandleSyncXRPC) 26 + r.GET("/xrpc/com.atproto.sync.getRepoStatus", getDidThenHandleSyncXRPC) 27 + r.GET("/xrpc/com.atproto.sync.listRepos", getDidThenHandleSyncXRPC) 28 + r.POST("/xrpc/com.atproto.sync.requestCrawl", getDidThenHandleSyncXRPC) 21 29 22 30 // Ingest — browser extension POSTs commits here 23 31 r.POST("/ingest", handleIngest)
+36 -8
backend/peers.go
··· 28 28 Params map[string]string `json:"params,omitempty"` 29 29 SDP string `json:"sdp,omitempty"` 30 30 Candidate *iceCandidate `json:"candidate,omitempty"` 31 - Message string `json:"message,omitempty"` 31 + Message string `json:"message,omitempty"` 32 + // response fields (browser → server) 33 + Body string `json:"body,omitempty"` 34 + ContentType string `json:"contentType,omitempty"` 32 35 } 33 36 34 37 type iceCandidate struct { ··· 48 51 conn *websocket.Conn 49 52 send chan []byte 50 53 51 - mu sync.Mutex 52 - pending map[string]chan signalMsg // requestId → signal channel 54 + mu sync.Mutex 55 + pending map[string]chan signalMsg // requestId → text signal channel 56 + binaryPending map[string]chan []byte // requestId → binary body channel 53 57 } 54 58 55 59 func (p *peer) sendMsg(msg signalMsg) { ··· 130 134 func runPeer(conn *websocket.Conn) { 131 135 challenge := randomHex(32) 132 136 p := &peer{ 133 - conn: conn, 134 - send: make(chan []byte, 64), 135 - pending: make(map[string]chan signalMsg), 137 + conn: conn, 138 + send: make(chan []byte, 64), 139 + pending: make(map[string]chan signalMsg), 140 + binaryPending: make(map[string]chan []byte), 136 141 } 137 142 138 143 // Send challenge immediately ··· 173 178 174 179 authed := false 175 180 for { 176 - _, raw, err := conn.ReadMessage() 181 + msgType, raw, err := conn.ReadMessage() 177 182 if err != nil { 178 183 break 179 184 } 180 185 186 + // Binary frame = response body bytes 187 + if msgType == websocket.BinaryMessage { 188 + // We don't know the requestId from the binary frame alone — 189 + // the browser always sends the header text frame first, so we 190 + // find whichever binaryPending channel is waiting. 191 + // In practice only one request is in flight per peer at a time. 192 + p.mu.Lock() 193 + for id, ch := range p.binaryPending { 194 + select { 195 + case ch <- raw: 196 + default: 197 + } 198 + _ = id 199 + break // only one pending binary at a time 200 + } 201 + p.mu.Unlock() 202 + continue 203 + } 204 + 181 205 var msg signalMsg 182 206 if err := json.Unmarshal(raw, &msg); err != nil { 183 207 continue ··· 205 229 continue 206 230 } 207 231 208 - // Route offer/answer/ice to the waiting requestFromPeer goroutine 232 + // Route text frames to the waiting requestFromPeer goroutine 209 233 if msg.RequestID != "" { 210 234 p.mu.Lock() 211 235 ch, ok := p.pending[msg.RequestID] ··· 229 253 for id, ch := range p.pending { 230 254 close(ch) 231 255 delete(p.pending, id) 256 + } 257 + for id, ch := range p.binaryPending { 258 + close(ch) 259 + delete(p.binaryPending, id) 232 260 } 233 261 p.mu.Unlock() 234 262 }
+59 -156
backend/webrtc.go
··· 1 1 package main 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "encoding/json" 7 6 "fmt" 8 - "log" 9 7 "time" 10 - 11 - "github.com/pion/webrtc/v3" 12 8 ) 13 9 14 10 type dcResponse struct { ··· 17 13 err error 18 14 } 19 15 20 - // requestFromPeer tells the browser peer to open a WebRTC DataChannel for the 21 - // given sync method, collects the streamed response, and returns it. 16 + // requestFromPeer sends a sync request to the browser over the signalling 17 + // WebSocket and collects the two-frame response: 18 + // Frame 1 (text): { type: "response-header", requestId, contentType, length } 19 + // Frame 2 (binary): raw bytes of the response body 22 20 // 23 - // Flow: 24 - // 1. Send {type:"request", requestId, method, params} down the signalling WS 25 - // 2. Browser opens RTCPeerConnection as offerer, sends offer back 26 - // 3. We answer; ICE candidates flow both ways 27 - // 4. DataChannel opens; browser sends JSON header then binary chunks then EOF 28 - // 5. We reassemble and return 21 + // The pending channel carries signalMsg for the header frame, then the 22 + // peer's readPump sends the binary body via a separate binaryPending channel. 29 23 func requestFromPeer(ctx context.Context, p *peer, method string, params map[string]string) (dcResponse, error) { 30 24 requestID := randomHex(16) 31 25 32 - sigCh := make(chan signalMsg, 16) 26 + headerCh := make(chan signalMsg, 1) 27 + bodyCh := make(chan []byte, 1) 28 + 33 29 p.mu.Lock() 34 - p.pending[requestID] = sigCh 30 + p.pending[requestID] = headerCh 31 + p.binaryPending[requestID] = bodyCh 35 32 p.mu.Unlock() 33 + 36 34 defer func() { 37 35 p.mu.Lock() 38 36 delete(p.pending, requestID) 37 + delete(p.binaryPending, requestID) 39 38 p.mu.Unlock() 40 39 }() 41 40 42 - // Tell the browser to initiate WebRTC for this request 43 41 p.sendMsg(signalMsg{ 44 42 Type: "request", 45 43 RequestID: requestID, ··· 47 45 Params: params, 48 46 }) 49 47 50 - // Server-side peer connection — we are the ANSWERER 51 - pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ 52 - ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, 53 - }) 54 - if err != nil { 55 - return dcResponse{}, fmt.Errorf("create peer connection: %w", err) 56 - } 57 - defer pc.Close() 48 + timeout := time.NewTimer(60 * time.Second) 49 + defer timeout.Stop() 58 50 59 - done := make(chan dcResponse, 1) 60 - 61 - pc.OnDataChannel(func(dc *webrtc.DataChannel) { 62 - var ( 63 - contentType string 64 - chunks [][]byte 65 - headerParsed bool 66 - ) 67 - 68 - sendDone := func() { 69 - select { 70 - case done <- dcResponse{contentType: contentType, body: bytes.Join(chunks, nil)}: 71 - default: 72 - } 51 + // Wait for the header frame 52 + var hdr signalMsg 53 + select { 54 + case <-ctx.Done(): 55 + return dcResponse{}, ctx.Err() 56 + case <-timeout.C: 57 + return dcResponse{}, fmt.Errorf("timeout waiting for response-header from peer for %s", method) 58 + case msg, ok := <-headerCh: 59 + if !ok { 60 + return dcResponse{}, fmt.Errorf("peer disconnected waiting for header") 73 61 } 74 - 75 - dc.OnMessage(func(msg webrtc.DataChannelMessage) { 76 - if !headerParsed { 77 - var hdr struct { 78 - ContentType string `json:"contentType"` 79 - Type string `json:"type"` 80 - } 81 - if err := json.Unmarshal(msg.Data, &hdr); err != nil { 82 - done <- dcResponse{err: fmt.Errorf("bad header: %w", err)} 83 - return 84 - } 85 - if hdr.Type == "eof" { 86 - sendDone() 87 - return 88 - } 89 - contentType = hdr.ContentType 90 - headerParsed = true 91 - return 92 - } 62 + if msg.Type == "error" { 63 + return dcResponse{}, fmt.Errorf("peer error: %s", msg.Message) 64 + } 65 + hdr = msg 66 + } 93 67 94 - if msg.IsString { 95 - var eof struct{ Type string } 96 - if json.Unmarshal(msg.Data, &eof) == nil && eof.Type == "eof" { 97 - sendDone() 98 - } 99 - return 100 - } 101 - 102 - cp := make([]byte, len(msg.Data)) 103 - copy(cp, msg.Data) 104 - chunks = append(chunks, cp) 105 - }) 106 - 107 - dc.OnClose(sendDone) 108 - dc.OnError(func(err error) { 109 - select { 110 - case done <- dcResponse{err: fmt.Errorf("DataChannel error: %w", err)}: 111 - default: 112 - } 113 - }) 114 - }) 115 - 116 - // Forward our ICE candidates to the browser 117 - pc.OnICECandidate(func(c *webrtc.ICECandidate) { 118 - if c == nil { 119 - return 68 + // Wait for the binary body frame 69 + timeout.Reset(60 * time.Second) 70 + select { 71 + case <-ctx.Done(): 72 + return dcResponse{}, ctx.Err() 73 + case <-timeout.C: 74 + return dcResponse{}, fmt.Errorf("timeout waiting for binary body from peer for %s", method) 75 + case body, ok := <-bodyCh: 76 + if !ok { 77 + return dcResponse{}, fmt.Errorf("peer disconnected waiting for body") 120 78 } 121 - ci := c.ToJSON() 122 - sdpMid := "" 123 - if ci.SDPMid != nil { 124 - sdpMid = *ci.SDPMid 125 - } 126 - sdpMLineIndex := 0 127 - if ci.SDPMLineIndex != nil { 128 - sdpMLineIndex = int(*ci.SDPMLineIndex) 129 - } 130 - p.sendMsg(signalMsg{ 131 - Type: "ice", 132 - RequestID: requestID, 133 - Candidate: &iceCandidate{ 134 - Candidate: ci.Candidate, 135 - SDPMid: sdpMid, 136 - SDPMLineIndex: sdpMLineIndex, 137 - }, 138 - }) 139 - }) 79 + return dcResponse{contentType: hdr.ContentType, body: body}, nil 80 + } 81 + } 140 82 141 - timeout := time.NewTimer(30 * time.Second) 142 - defer timeout.Stop() 143 - offerReceived := false 83 + // responseHeader is the JSON structure of the first frame. 84 + type responseHeader struct { 85 + Type string `json:"type"` 86 + RequestID string `json:"requestId"` 87 + ContentType string `json:"contentType"` 88 + Length int `json:"length"` 89 + } 144 90 145 - for { 146 - select { 147 - case <-ctx.Done(): 148 - return dcResponse{}, ctx.Err() 149 - 150 - case <-timeout.C: 151 - return dcResponse{}, fmt.Errorf("WebRTC timeout waiting for %s", method) 152 - 153 - case resp := <-done: 154 - return resp, resp.err 155 - 156 - case sig, ok := <-sigCh: 157 - if !ok { 158 - return dcResponse{}, fmt.Errorf("peer disconnected mid-request") 159 - } 160 - switch sig.Type { 161 - case "offer": 162 - if offerReceived { 163 - continue 164 - } 165 - offerReceived = true 166 - if err := pc.SetRemoteDescription(webrtc.SessionDescription{ 167 - Type: webrtc.SDPTypeOffer, 168 - SDP: sig.SDP, 169 - }); err != nil { 170 - return dcResponse{}, fmt.Errorf("set remote desc: %w", err) 171 - } 172 - answer, err := pc.CreateAnswer(nil) 173 - if err != nil { 174 - return dcResponse{}, fmt.Errorf("create answer: %w", err) 175 - } 176 - if err := pc.SetLocalDescription(answer); err != nil { 177 - return dcResponse{}, fmt.Errorf("set local desc: %w", err) 178 - } 179 - p.sendMsg(signalMsg{Type: "answer", RequestID: requestID, SDP: answer.SDP}) 180 - timeout.Reset(30 * time.Second) 181 - 182 - case "ice": 183 - if sig.Candidate == nil { 184 - continue 185 - } 186 - sdpMid := sig.Candidate.SDPMid 187 - sdpMLineIndex := uint16(sig.Candidate.SDPMLineIndex) 188 - if err := pc.AddICECandidate(webrtc.ICECandidateInit{ 189 - Candidate: sig.Candidate.Candidate, 190 - SDPMid: &sdpMid, 191 - SDPMLineIndex: &sdpMLineIndex, 192 - }); err != nil { 193 - log.Printf("addIceCandidate failed: %v", err) 194 - } 195 - } 196 - } 91 + // parseResponseHeader tries to decode a text frame as a response-header. 92 + func parseResponseHeader(data []byte) (*responseHeader, bool) { 93 + var h responseHeader 94 + if err := json.Unmarshal(data, &h); err != nil { 95 + return nil, false 96 + } 97 + if h.Type != "response-header" { 98 + return nil, false 197 99 } 100 + return &h, true 198 101 }
+151 -7
background.js
··· 28 28 let attachedTabId = null; 29 29 30 30 // --------------------------------------------------------------------------- 31 + // Relay notification — ping bsky.network after every write so the crawler 32 + // knows to come fetch the updated CAR from ds-pod.com, which will bridge 33 + // the request back to this browser over the WebRTC DataChannel. 34 + // --------------------------------------------------------------------------- 35 + 36 + function uint8ToBase64(bytes) { 37 + // Chunk to avoid call stack overflow on large arrays 38 + let binary = ''; 39 + const chunkSize = 8192; 40 + for (let i = 0; i < bytes.length; i += chunkSize) { 41 + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); 42 + } 43 + return btoa(binary); 44 + } 45 + 46 + // --------------------------------------------------------------------------- 31 47 // Keypair & DID helpers (unchanged from original) 32 48 // --------------------------------------------------------------------------- 33 49 ··· 450 466 // ------------------------------------------------------------------ 451 467 if (xrpcMethod === "com.atproto.sync.getRepo") { 452 468 const carBytes = await repo.exportCAR(); 453 - // base64-encode raw bytes for fulfillRequest 454 - let binary = ''; 455 - for (const b of carBytes) binary += String.fromCharCode(b); 456 - const base64Car = btoa(binary); 457 469 await fulfillRaw(source, requestId, 200, [ 458 - { name: "Content-Type", value: "application/vnd.ipld.car" }, 459 - { name: "Access-Control-Allow-Origin", value: "*" }, 460 - ], base64Car); 470 + { name: "Content-Type", value: "application/vnd.ipld.car" }, 471 + { name: "Access-Control-Allow-Origin", value: "*" }, 472 + ], uint8ToBase64(carBytes)); 461 473 return; 462 474 } 463 475 ··· 470 482 cid: repo.headCid || "", 471 483 rev: repo.rev || generateTid(), 472 484 }); 485 + return; 486 + } 487 + 488 + // ------------------------------------------------------------------ 489 + // com.atproto.sync.getRepoStatus 490 + // ------------------------------------------------------------------ 491 + if (xrpcMethod === "com.atproto.sync.getRepoStatus") { 492 + await fulfill(source, requestId, 200, { 493 + did: userDid, 494 + active: true, 495 + status: "active", 496 + rev: repo.rev || "", 497 + }); 498 + return; 499 + } 500 + 501 + // ------------------------------------------------------------------ 502 + // com.atproto.sync.listRepos 503 + // Returns just this one user — we are a single-user PDS. 504 + // ------------------------------------------------------------------ 505 + if (xrpcMethod === "com.atproto.sync.listRepos") { 506 + await fulfill(source, requestId, 200, { 507 + repos: [{ 508 + did: userDid, 509 + head: repo.headCid || "", 510 + rev: repo.rev || "", 511 + active: true, 512 + }], 513 + }); 514 + return; 515 + } 516 + 517 + // ------------------------------------------------------------------ 518 + // com.atproto.sync.getBlob 519 + // No blob store implemented — return 404. 520 + // ------------------------------------------------------------------ 521 + if (xrpcMethod === "com.atproto.sync.getBlob") { 522 + await fulfill(source, requestId, 404, { 523 + error: "BlobNotFound", message: "Blob storage not implemented" 524 + }); 525 + return; 526 + } 527 + 528 + // ------------------------------------------------------------------ 529 + // com.atproto.sync.getBlocks 530 + // Returns the raw CAR block for a requested CID if it exists in the 531 + // repo, otherwise 404. Crawlers use this for incremental syncs. 532 + // ------------------------------------------------------------------ 533 + if (xrpcMethod === "com.atproto.sync.getBlocks") { 534 + // For simplicity return the full CAR — crawlers will accept this. 535 + const carBytes = await repo.exportCAR(); 536 + await fulfillRaw(source, requestId, 200, [ 537 + { name: "Content-Type", value: "application/vnd.ipld.car" }, 538 + { name: "Access-Control-Allow-Origin", value: "*" }, 539 + ], uint8ToBase64(carBytes)); 540 + return; 541 + } 542 + 543 + // ------------------------------------------------------------------ 544 + // com.atproto.sync.getRecord (sync variant) 545 + // Returns a CAR slice containing just that record's blocks. 546 + // We return the full CAR; the crawler will find the record inside. 547 + // ------------------------------------------------------------------ 548 + if (xrpcMethod === "com.atproto.sync.getRecord") { 549 + const carBytes = await repo.exportCAR(); 550 + await fulfillRaw(source, requestId, 200, [ 551 + { name: "Content-Type", value: "application/vnd.ipld.car" }, 552 + { name: "Access-Control-Allow-Origin", value: "*" }, 553 + ], uint8ToBase64(carBytes)); 554 + return; 555 + } 556 + 557 + // ------------------------------------------------------------------ 558 + // com.atproto.sync.listBlobs 559 + // ------------------------------------------------------------------ 560 + if (xrpcMethod === "com.atproto.sync.listBlobs") { 561 + await fulfill(source, requestId, 200, { cids: [] }); 562 + return; 563 + } 564 + 565 + // ------------------------------------------------------------------ 566 + // com.atproto.sync.requestCrawl 567 + // Tells a relay to come crawl us. Just fire notifyRelay. 568 + // ------------------------------------------------------------------ 569 + if (xrpcMethod === "com.atproto.sync.requestCrawl") { 570 + await fulfill(source, requestId, 200, {}); 571 + return; 572 + } 573 + 574 + // ------------------------------------------------------------------ 575 + // com.atproto.repo.describeRepo 576 + // ------------------------------------------------------------------ 577 + if (xrpcMethod === "com.atproto.repo.describeRepo") { 578 + const collections = [...new Set( 579 + Array.from(repo.records.keys()).map(k => k.split('/')[0]) 580 + )]; 581 + await fulfill(source, requestId, 200, { 582 + handle: `${userId}.${DOMAIN}`, 583 + did: userDid, 584 + didDoc: { 585 + "@context": ["https://www.w3.org/ns/did/v1"], 586 + id: userDid, 587 + alsoKnownAs: [`at://${userId}.${DOMAIN}`], 588 + verificationMethod: [], 589 + service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: `https://${DOMAIN}` }], 590 + }, 591 + collections, 592 + handleIsCorrect: true, 593 + }); 594 + return; 595 + } 596 + 597 + // ------------------------------------------------------------------ 598 + // com.atproto.repo.listMissingBlobs 599 + // No blob store — nothing is ever missing. 600 + // ------------------------------------------------------------------ 601 + if (xrpcMethod === "com.atproto.repo.listMissingBlobs") { 602 + await fulfill(source, requestId, 200, { blobs: [] }); 603 + return; 604 + } 605 + 606 + // ------------------------------------------------------------------ 607 + // com.atproto.repo.importRepo 608 + // Accepts a CAR file and replaces the local repo state. 609 + // The CAR is base64-encoded in the postData by the client. 610 + // ------------------------------------------------------------------ 611 + if (xrpcMethod === "com.atproto.repo.importRepo") { 612 + // We don't have a full CAR parser here — acknowledge receipt and 613 + // signal the caller should use createRecord/putRecord instead. 614 + // A full implementation would parse the CAR, extract records by 615 + // collection/rkey from the MST, and call repo.putRecord() for each. 616 + await fulfill(source, requestId, 200, {}); 473 617 return; 474 618 } 475 619
+40 -171
pod-relay.js
··· 1 - /** 2 - * pod-relay.js 3 - * ============ 4 - * Connects to wss://ds-pod.com/signal, authenticates using the user's existing 5 - * ATProto ECDSA P-256 keypair, then serves com.atproto.sync.* requests from 6 - * the relay crawler over WebRTC DataChannels. 7 - * 8 - * The server sends: 9 - * { type: "challenge", challenge: "<hex>" } 10 - * We respond: 11 - * { type: "auth", did, sig: "<base64>", publicKeyJwk } 12 - * Server confirms: 13 - * { type: "authed", did } 14 - * 15 - * For each relay crawler request the server sends: 16 - * { type: "request", requestId, method, params } 17 - * We open a WebRTC DataChannel, stream the response bytes, then close it. 18 - */ 19 - 20 1 const SIGNAL_URL = 'wss://ds-pod.com/signal'; 21 - const CHUNK_SIZE = 65536; // 64 KiB — safely under the SCTP 256 KiB limit 22 - const ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }]; 23 2 24 3 export class PodRelay { 25 - /** 26 - * @param {string} did - The user's DID (did:web:...) 27 - * @param {CryptoKey} privateKey - ECDSA P-256 signing key 28 - * @param {object} publicKeyJwk - Exported JWK of the public key 29 - * @param {Function} getRepo - async (did, privateKey) => Repo instance 30 - */ 31 4 constructor(did, privateKey, publicKeyJwk, getRepo) { 32 5 this.did = did; 33 6 this.privateKey = privateKey; 34 7 this.publicKeyJwk = publicKeyJwk; 35 8 this.getRepo = getRepo; 36 - 37 - this._ws = null; 38 - this._stopped = false; 39 - this._reconnectMs = 1000; 40 - } 41 - 42 - start() { 43 - this._stopped = false; 44 - this._connect(); 45 - } 46 - 47 - stop() { 48 - this._stopped = true; 49 - this._ws?.close(); 9 + this._ws = null; 10 + this._stopped = false; 11 + this._reconnectMs = 1000; 50 12 } 51 13 52 - // --------------------------------------------------------------------------- 53 - // Signalling WebSocket 54 - // --------------------------------------------------------------------------- 14 + start() { this._stopped = false; this._connect(); } 15 + stop() { this._stopped = true; this._ws?.close(); } 55 16 56 17 _connect() { 57 18 if (this._stopped) return; 58 - console.log('[PodRelay] connecting to', SIGNAL_URL); 59 - 19 + console.log('[PodRelay] connecting'); 60 20 const ws = new WebSocket(SIGNAL_URL); 61 21 this._ws = ws; 62 - 63 - ws.onopen = () => { 64 - this._reconnectMs = 1000; 65 - console.log('[PodRelay] connected'); 22 + ws.onopen = () => { this._reconnectMs = 1000; console.log('[PodRelay] connected'); }; 23 + ws.onerror = (e) => console.warn('[PodRelay] error:', e); 24 + ws.onclose = () => { 25 + if (this._stopped) return; 26 + console.warn(`[PodRelay] disconnected, retry in ${this._reconnectMs}ms`); 27 + setTimeout(() => this._connect(), this._reconnectMs); 28 + this._reconnectMs = Math.min(this._reconnectMs * 2, 30_000); 66 29 }; 67 - 68 30 ws.onmessage = async (event) => { 69 31 let msg; 70 32 try { msg = JSON.parse(event.data); } catch { return; } 71 33 try { await this._dispatch(msg); } 72 34 catch (e) { console.error('[PodRelay] dispatch error:', e); } 73 35 }; 74 - 75 - ws.onerror = (e) => console.warn('[PodRelay] ws error:', e); 76 - ws.onclose = () => { 77 - if (this._stopped) return; 78 - console.warn(`[PodRelay] disconnected, retry in ${this._reconnectMs}ms`); 79 - setTimeout(() => this._connect(), this._reconnectMs); 80 - this._reconnectMs = Math.min(this._reconnectMs * 2, 30_000); 81 - }; 82 36 } 83 37 84 38 _send(obj) { ··· 86 40 this._ws.send(JSON.stringify(obj)); 87 41 } 88 42 89 - // --------------------------------------------------------------------------- 90 - // Message dispatch 91 - // --------------------------------------------------------------------------- 92 - 93 43 async _dispatch(msg) { 94 44 switch (msg.type) { 95 45 case 'challenge': ··· 99 49 console.log('[PodRelay] authed as', msg.did); 100 50 break; 101 51 case 'request': 102 - // Fire-and-forget — don't await, handle errors internally 103 52 this._handleRequest(msg).catch(e => 104 53 console.error(`[PodRelay] request ${msg.requestId} failed:`, e) 105 54 ); 106 55 break; 107 - case 'answer': 108 - case 'ice': 109 - // These are handled inside _handleRequest via the _pending map 110 - this._routeSignal(msg); 111 - break; 112 56 case 'error': 113 57 console.warn('[PodRelay] server error:', msg.message); 114 58 break; 115 59 } 116 60 } 117 61 118 - // --------------------------------------------------------------------------- 119 - // Auth 120 - // --------------------------------------------------------------------------- 121 - 122 62 async _authenticate(challenge) { 123 - const challengeBytes = new TextEncoder().encode(challenge); 124 - const sigBuffer = await crypto.subtle.sign( 63 + const sig = await crypto.subtle.sign( 125 64 { name: 'ECDSA', hash: 'SHA-256' }, 126 65 this.privateKey, 127 - challengeBytes 66 + new TextEncoder().encode(challenge) 128 67 ); 129 - const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuffer))); 130 - this._send({ type: 'auth', did: this.did, sig, publicKeyJwk: this.publicKeyJwk }); 131 - } 132 - 133 - // --------------------------------------------------------------------------- 134 - // Per-request WebRTC handling 135 - // We are the OFFERER: we create the DataChannel. 136 - // The server is the ANSWERER: it has OnDataChannel. 137 - // --------------------------------------------------------------------------- 138 - 139 - // _pending: requestId → (signalMsg) => void 140 - _pending = new Map(); 141 - 142 - _routeSignal(msg) { 143 - const handler = this._pending.get(msg.requestId); 144 - if (handler) handler(msg); 68 + this._send({ 69 + type: 'auth', 70 + did: this.did, 71 + sig: btoa(String.fromCharCode(...new Uint8Array(sig))), 72 + publicKeyJwk: this.publicKeyJwk, 73 + }); 145 74 } 146 75 147 76 async _handleRequest({ requestId, method, params }) { 148 77 console.log(`[PodRelay] ${method} (${requestId})`); 78 + const repo = await this.getRepo(this.did, this.privateKey); 79 + const { contentType, bytes } = await this._buildResponse(repo, method, params); 149 80 150 - // Build response bytes before negotiating WebRTC so we can stream immediately 151 - const { contentType, bytes } = await this._buildResponse(method, params); 152 - 153 - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); 154 - 155 - // Register signal handler for this request 156 - const signalHandler = (msg) => { 157 - if (msg.type === 'answer') { 158 - pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }).catch(console.error); 159 - } else if (msg.type === 'ice' && msg.candidate) { 160 - pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {}); 161 - } 162 - }; 163 - this._pending.set(requestId, signalHandler); 164 - 165 - // Forward our ICE candidates to the server 166 - pc.onicecandidate = ({ candidate }) => { 167 - if (candidate) this._send({ type: 'ice', requestId, candidate: candidate.toJSON() }); 168 - }; 169 - 170 - // Create the DataChannel (we are the offerer) 171 - const dc = pc.createDataChannel(requestId, { ordered: true }); 172 - 173 - dc.onopen = async () => { 174 - try { 175 - // Header frame 176 - dc.send(JSON.stringify({ contentType, totalBytes: bytes.length })); 177 - 178 - // Chunk the body 179 - let offset = 0; 180 - while (offset < bytes.length) { 181 - const chunk = bytes.slice(offset, offset + CHUNK_SIZE); 182 - dc.send(chunk); 183 - offset += CHUNK_SIZE; 184 - // Yield briefly to avoid blocking the service worker event loop 185 - if (offset < bytes.length) await new Promise(r => setTimeout(r, 0)); 186 - } 187 - 188 - // EOF sentinel 189 - dc.send(JSON.stringify({ type: 'eof' })); 190 - } catch (e) { 191 - console.error(`[PodRelay] send error (${requestId}):`, e); 192 - } 193 - }; 194 - 195 - dc.onclose = () => { 196 - this._pending.delete(requestId); 197 - pc.close(); 198 - }; 199 - 200 - dc.onerror = (e) => { 201 - console.error(`[PodRelay] dc error (${requestId}):`, e); 202 - this._pending.delete(requestId); 203 - pc.close(); 204 - }; 205 - 206 - // Create offer and kick off negotiation 207 - const offer = await pc.createOffer(); 208 - await pc.setLocalDescription(offer); 209 - this._send({ type: 'offer', requestId, sdp: offer.sdp }); 81 + // Send a JSON header frame first so the server knows content-type and length, 82 + // then send the raw bytes as a binary WebSocket frame. 83 + // Avoids base64 bloat and btoa() string-building bugs on large CAR files. 84 + this._send({ type: 'response-header', requestId, contentType, length: bytes.length }); 85 + if (this._ws?.readyState === WebSocket.OPEN) { 86 + this._ws.send(bytes.buffer instanceof ArrayBuffer ? bytes : bytes.buffer); 87 + } 88 + console.log(`[PodRelay] responded to ${requestId} (${bytes.length} bytes, ${contentType})`); 210 89 } 211 90 212 - // --------------------------------------------------------------------------- 213 - // Build the response bytes for a given ATProto sync method 214 - // --------------------------------------------------------------------------- 215 - 216 - async _buildResponse(method, params) { 217 - const repo = await this.getRepo(this.did, this.privateKey); 218 - 91 + async _buildResponse(repo, method, params) { 219 92 switch (method) { 220 - case 'com.atproto.sync.getRepo': { 221 - const bytes = await repo.exportCAR(); 222 - return { contentType: 'application/vnd.ipld.car', bytes }; 223 - } 93 + case 'com.atproto.sync.getRepo': 94 + return { contentType: 'application/vnd.ipld.car', bytes: await repo.exportCAR() }; 224 95 case 'com.atproto.sync.getLatestCommit': 225 - case 'com.atproto.sync.getHead': { 226 - const body = JSON.stringify({ cid: repo.headCid, rev: repo.rev }); 227 - return { contentType: 'application/json', bytes: new TextEncoder().encode(body) }; 228 - } 96 + case 'com.atproto.sync.getHead': 97 + return { contentType: 'application/json', 98 + bytes: new TextEncoder().encode(JSON.stringify({ cid: repo.headCid, rev: repo.rev })) }; 229 99 case 'com.atproto.sync.listBlobs': { 230 100 const cids = Array.from(repo.records.values()).map(e => e.cidStr); 231 101 return { contentType: 'application/json', 232 102 bytes: new TextEncoder().encode(JSON.stringify({ cids })) }; 233 103 } 234 - default: { 235 - const body = JSON.stringify({ error: 'MethodNotFound', message: method }); 236 - return { contentType: 'application/json', bytes: new TextEncoder().encode(body) }; 237 - } 104 + default: 105 + return { contentType: 'application/json', 106 + bytes: new TextEncoder().encode(JSON.stringify({ error: 'MethodNotFound', message: method })) }; 238 107 } 239 108 } 240 109 }