a proof of concept realtime collaborative text editor using atproto as a sync server jake.tngl.io/y-pds/
0

Configure Feed

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

ypds / y-pds.js
21 kB 670 lines
1import * as Y from "yjs"; 2import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate } from "y-protocols/awareness"; 3 4export const DOC_COLLECTION = "com.jakelazaroff.y-pds.doc"; 5export const UPDATE_COLLECTION = "com.jakelazaroff.y-pds.update"; 6export const AWARENESS_COLLECTION = "com.jakelazaroff.y-pds.awareness"; 7export const WEBRTC_COLLECTION = "com.jakelazaroff.y-pds.webrtc"; 8 9const encode = (/** @type {Uint8Array} */ update) => btoa(String.fromCharCode(...update)); 10const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); 11 12const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 13 14const s32encode = (/** @type {number} */ i) => { 15 let s = ""; 16 while (i) { 17 s = TID_CHARS[i % 32] + s; 18 i = Math.floor(i / 32); 19 } 20 return s; 21}; 22 23/** Generate an AT Protocol TID: a 13-character, lexicographically sortable record key. */ 24export const genTid = () => { 25 const timestamp = Date.now() * 1000 + Math.floor(Math.random() * 1000); 26 const clockId = Math.floor(Math.random() * 1024); 27 return s32encode(timestamp).padStart(11, "2") + s32encode(clockId).padStart(2, "2"); 28}; 29 30const MSG_DOC = 0; 31const MSG_AWARENESS = 1; 32 33const ICE_SERVERS = [ 34 { urls: "stun:stun.l.google.com:19302" }, 35]; 36 37export class YPdsProvider { 38 /** @type {Y.Doc} */ 39 #ydoc; 40 41 /** @type {Map<string, string>} */ 42 #pds = new Map(); 43 44 /** @param {string} did */ 45 async #getPDS(did) { 46 let pds = this.#pds.get(did); 47 if (pds) return pds; 48 49 const url = did.startsWith("did:web:") 50 ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 51 : `https://plc.directory/${did}`; 52 const doc = await fetch(url).then(r => r.json()); 53 54 pds = doc.service?.find(s => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 55 if (!pds) throw new Error(`no PDS found for ${did}`); 56 57 this.#pds.set(did, pds); 58 return pds; 59 } 60 61 /** 62 * @params {object} options 63 * @params {string} [options.rkey] 64 * @params {string} options.repo 65 * @params {string} options.collection 66 */ 67 async #getRecord({ repo, collection, rkey }) { 68 const pds = await this.#getPDS(repo); 69 const params = new URLSearchParams({ repo, collection, rkey }); 70 const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); 71 const data = await res.json(); 72 if (!res.ok) throw new Error(data.message); 73 return data; 74 } 75 76 /** 77 * @params {object} options 78 * @params {string} options.repo 79 * @params {string} options.collection 80 * @params {string} [options.cursor] 81 */ 82 async #listRecords({ repo, collection, cursor }) { 83 const pds = await this.#getPDS(repo); 84 const params = new URLSearchParams({ repo, collection, limit: "100" }); 85 if (cursor) params.set("cursor", cursor); 86 const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); 87 const data = await res.json(); 88 if (!res.ok) throw new Error(data.message); 89 return data; 90 } 91 92 /** 93 * @params {object} options 94 * @params {string} options.repo 95 * @params {string} options.collection 96 * @params {object} options.record 97 * @params {string} [options.rkey] 98 */ 99 async #upsertRecord({ repo, collection, rkey, record }) { 100 const pds = await this.#getPDS(repo); 101 const verb = rkey ? "com.atproto.repo.putRecord" : "com.atproto.repo.createRecord"; 102 const res = await fetch(`${pds}/xrpc/${verb}`, { 103 method: "POST", 104 headers: { 105 "content-type": "application/json", 106 "x-atsw-did": repo, 107 }, 108 body: JSON.stringify({ repo, collection, rkey, record: { $type: collection, ...record } }), 109 }); 110 const data = await res.json(); 111 if (!res.ok) throw new Error(data.message); 112 return data; 113 } 114 115 #ownerDid = ""; 116 #rkey = ""; 117 118 /** @type {Array<{uri: string, cid: string, repo: string, value: {docId: string, update: string, createdAt: string}}>} */ 119 #allRecords = []; 120 121 /** @type {Map<string, {repo: string, uri: string, cid: string, value: object}>} */ 122 #awarenessMap = new Map(); 123 124 /** @type {Set<WebSocket>} */ 125 #sockets = new Set(); 126 127 // Subscribe to multiple Jetstream instances at once: any single instance can 128 // silently drop or lag commits from some repos (e.g. jetstream2.us-east 129 // missing us-west repos), so we fan out and dedupe. Yjs updates are 130 // idempotent, but deduping keeps side effects (record list, load(), WebRTC 131 // signaling) from firing once per instance. 132 #jetstreams = [ 133 "wss://jetstream1.us-east.bsky.network/subscribe", 134 "wss://jetstream2.us-east.bsky.network/subscribe", 135 "wss://jetstream1.us-west.bsky.network/subscribe", 136 ]; 137 138 /** @type {Map<string, number>} per-endpoint cursor (time_us of last event) */ 139 #cursors = new Map(); 140 141 /** @type {Set<string>} commit keys already processed, for cross-instance dedupe */ 142 #seen = new Set(); 143 /** @type {string[]} insertion order for bounding #seen */ 144 #seenOrder = []; 145 146 /** @type {{ editors: string[], [key: string]: unknown } | null} */ 147 #doc = null; 148 149 #destroyed = false; 150 151 /** 152 * @param {object} options 153 * @param {Y.Doc} options.ydoc 154 * @param {string} options.pds 155 * @param {string} options.atUri 156 * @param {string} options.did 157 * @param {Awareness} [options.awareness] 158 * @param {string} [options.jetstream] 159 */ 160 /** @type {Map<string, { pc: RTCPeerConnection }>} */ 161 #peers = new Map(); 162 163 /** @type {((editors: string[]) => void) | null} */ 164 onMembersChange = null; 165 166 /** @type {(() => void) | null} */ 167 onRecordAdded = null; 168 169 /** @type {(() => void) | null} */ 170 onAwarenessChange = null; 171 172 /** @type {(() => void) | null} */ 173 onPauseChange = null; 174 175 /** @type {((did: string) => void) | null} */ 176 onPeerConnect = null; 177 178 /** @type {((did: string) => void) | null} */ 179 onPeerDisconnect = null; 180 181 #paused = false; 182 get paused() { return this.#paused; } 183 184 #throttle = 5000; 185 186 /** @type {Uint8Array[]} */ 187 #pendingUpdates = []; 188 #updateTimerId = null; 189 190 #awarenessDirty = false; 191 #awarenessTimerId = null; 192 193 constructor({ ydoc, pds, atUri, did, awareness, jetstream, jetstreams, throttle }) { 194 this.#pds.set(did, pds); 195 this.#ydoc = ydoc; 196 197 const [ownerDid, , rkey] = atUri.slice("at://".length).split("/"); 198 this.#ownerDid = ownerDid; 199 this.#rkey = rkey; 200 this.did = did; 201 this.awareness = awareness ?? new Awareness(ydoc); 202 if (jetstreams) this.#jetstreams = jetstreams; 203 else if (jetstream) this.#jetstreams = [jetstream]; 204 if (throttle !== undefined) this.#throttle = throttle; 205 206 this.awareness.on("change", this.#onAwarenessChange); 207 this.#ydoc.on("update", this.#onUpdate); 208 } 209 210 async load() { 211 try { 212 const data = await this.#getRecord({ 213 repo: this.#ownerDid, 214 collection: DOC_COLLECTION, 215 rkey: this.#rkey, 216 }); 217 this.#doc = data.value; 218 this.onMembersChange?.(this.#doc.editors); 219 } catch { 220 // if the fetch fails and the document is not in our own repo, 221 // throw an error rather than attempting to create it 222 if (this.did !== this.#ownerDid) throw new Error("Document not found"); 223 224 // create the document 225 this.#doc = { docId: this.#rkey, editors: [], createdAt: new Date().toISOString() }; 226 await this.#upsertRecord({ 227 repo: this.did, 228 collection: DOC_COLLECTION, 229 rkey: this.#rkey, 230 record: this.#doc, 231 }); 232 this.onMembersChange?.(this.#doc.editors); 233 } 234 235 // fetch updates from the owner and all editors 236 const repos = [this.#ownerDid].concat(this.#doc.editors); 237 const updates = await Promise.all(repos.map(repo => this.#fetchUpdates(repo))); 238 239 // apply all the updates 240 Y.transact(this.#ydoc, () => { 241 for (const { value } of updates.flat()) { 242 Y.applyUpdate(this.#ydoc, decode(value.update)); 243 } 244 }, this); 245 246 this.#allRecords = updates.flat(); 247 248 // Persist a consolidated copy in our own PDS so we keep the full document 249 // even if other editors later delete their records. 250 await this.#consolidate(); 251 252 this.#subscribe(); 253 } 254 255 async #consolidate() { 256 // Nothing to back up if every record is already ours. 257 if (!this.#allRecords.some(r => r.repo !== this.did)) return; 258 259 const merged = Y.encodeStateAsUpdate(this.#ydoc); 260 await this.#upsertRecord({ 261 repo: this.did, 262 collection: UPDATE_COLLECTION, 263 // Fixed rkey = the doc's own key, so putRecord overwrites rather than 264 // accumulating a new record on every load. 265 rkey: this.#rkey, 266 record: { docId: this.#rkey, update: encode(merged), createdAt: new Date().toISOString() }, 267 }); 268 } 269 270 async #fetchUpdates(repo) { 271 const records = []; 272 let cursor; 273 274 do { 275 const res = await this.#listRecords({ repo, collection: UPDATE_COLLECTION, cursor }); 276 records.push( 277 ...res.records 278 .filter(r => r.value.docId === this.#rkey) 279 .map(r => ({ ...r, repo })), 280 ); 281 cursor = res.cursor; 282 } while (cursor); 283 284 return records; 285 } 286 287 /** 288 * @param {Uint8Array} update 289 * @param {unknown} origin 290 */ 291 #flushUpdate = async () => { 292 this.#updateTimerId = null; 293 if (this.#pendingUpdates.length === 0) return; 294 const merged = this.#pendingUpdates.length === 1 295 ? this.#pendingUpdates[0] 296 : Y.mergeUpdates(this.#pendingUpdates); 297 this.#pendingUpdates = []; 298 const createdAt = new Date().toISOString(); 299 const data = await this.#upsertRecord({ 300 repo: this.did, 301 collection: UPDATE_COLLECTION, 302 record: { docId: this.#rkey, update: encode(merged), createdAt }, 303 }); 304 this.#allRecords.push({ 305 uri: data.uri, 306 cid: data.cid, 307 repo: this.did, 308 value: { docId: this.#rkey, update: encode(merged), createdAt }, 309 }); 310 this.onRecordAdded?.(); 311 }; 312 313 #onUpdate = (update, origin) => { 314 if (origin === this) return; 315 this.#pendingUpdates.push(update); 316 if (!this.#paused && this.#updateTimerId == null) { 317 this.#updateTimerId = setTimeout(this.#flushUpdate, this.#throttle); 318 } 319 }; 320 321 /** @param {{ added: number[], updated: number[] }} changes */ 322 #flushAwareness = async () => { 323 this.#awarenessTimerId = null; 324 if (!this.#awarenessDirty) return; 325 this.#awarenessDirty = false; 326 const update = encodeAwarenessUpdate(this.awareness, [this.awareness.clientID]); 327 const createdAt = new Date().toISOString(); 328 const data = await this.#upsertRecord({ 329 repo: this.did, 330 collection: AWARENESS_COLLECTION, 331 rkey: this.#rkey, 332 record: { docId: this.#rkey, awareness: encode(update), createdAt }, 333 }); 334 this.#awarenessMap.set(this.did, { 335 repo: this.did, 336 uri: data.uri, 337 cid: data.cid, 338 value: { docId: this.#rkey, awareness: encode(update), createdAt }, 339 }); 340 this.onAwarenessChange?.(); 341 }; 342 343 #onAwarenessChange = ({ added, updated }) => { 344 const ours = this.awareness.clientID; 345 if (!added.includes(ours) && !updated.includes(ours)) return; 346 this.#awarenessDirty = true; 347 if (!this.#paused && this.#awarenessTimerId == null) { 348 this.#awarenessTimerId = setTimeout(this.#flushAwareness, this.#throttle); 349 } 350 }; 351 352 getMembers() { 353 return this.#doc.editors; 354 } 355 356 getAwarenessRecords() { 357 return [...this.#awarenessMap.values()]; 358 } 359 360 getDebugInfo() { 361 return { 362 doc: this.#doc, 363 docUri: `at://${this.#ownerDid}/${DOC_COLLECTION}/${this.#rkey}`, 364 ownerDid: this.#ownerDid, 365 rkey: this.#rkey, 366 records: [...this.#allRecords], 367 }; 368 } 369 370 pause() { 371 if (this.#paused) return; 372 this.#paused = true; 373 clearTimeout(this.#updateTimerId); 374 this.#updateTimerId = null; 375 clearTimeout(this.#awarenessTimerId); 376 this.#awarenessTimerId = null; 377 this.onPauseChange?.(); 378 } 379 380 async play() { 381 if (!this.#paused) return; 382 this.#paused = false; 383 384 // Re-fetch all repos and apply only records we haven't seen yet, 385 // merged into one update so the ydoc sees a single transaction. 386 const repos = [this.#ownerDid, ...this.#doc.editors]; 387 const fetched = (await Promise.all(repos.map(r => this.#fetchUpdates(r)))).flat(); 388 const seenUris = new Set(this.#allRecords.map(r => r.uri)); 389 const newRecords = fetched.filter(r => !seenUris.has(r.uri)); 390 if (newRecords.length > 0) { 391 const merged = Y.mergeUpdates(newRecords.map(r => decode(r.value.update))); 392 Y.applyUpdate(this.#ydoc, merged, this); 393 this.#allRecords.push(...newRecords); 394 this.onRecordAdded?.(); 395 } 396 397 // Flush buffered local edits as one record. 398 await this.#flushUpdate(); 399 400 this.onPauseChange?.(); 401 } 402 403 getPeers() { 404 return [...this.#peers.keys()]; 405 } 406 407 async connect(dids) { 408 await Promise.all(dids.map(async did => { 409 if (did === this.did || this.#peers.has(did)) return; 410 411 const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); 412 const channel = pc.createDataChannel("yjs"); 413 this.#peers.set(did, { pc }); 414 this.#setupDataChannel(did, channel); 415 416 const icePromise = this.#waitForIce(pc); 417 const offer = await pc.createOffer(); 418 await pc.setLocalDescription(offer); 419 await icePromise; 420 421 await this.#upsertRecord({ 422 repo: this.did, 423 collection: WEBRTC_COLLECTION, 424 record: { 425 target: did, 426 docId: this.#rkey, 427 type: "offer", 428 sdp: pc.localDescription.sdp, 429 createdAt: new Date().toISOString(), 430 }, 431 }); 432 })); 433 } 434 435 /** Wait for ICE gathering to finish. Call before setLocalDescription to avoid missing candidates. */ 436 #waitForIce(pc) { 437 return new Promise(resolve => { 438 if (pc.iceGatheringState === "complete") { resolve(); return; } 439 const timer = setTimeout(resolve, 10_000); 440 pc.addEventListener("icecandidate", function check(e) { 441 if (!e.candidate) { 442 clearTimeout(timer); 443 pc.removeEventListener("icecandidate", check); 444 resolve(); 445 } 446 }); 447 }); 448 } 449 450 /** @param {string} did @param {RTCDataChannel} channel */ 451 #setupDataChannel(did, channel) { 452 channel.binaryType = "arraybuffer"; 453 454 const send = (type, data) => { 455 if (channel.readyState !== "open") return; 456 const msg = new Uint8Array(1 + data.byteLength); 457 msg[0] = type; 458 msg.set(data, 1); 459 channel.send(msg); 460 }; 461 462 channel.onopen = () => { 463 this.onPeerConnect?.(did); 464 send(MSG_DOC, Y.encodeStateAsUpdate(this.#ydoc)); 465 send(MSG_AWARENESS, encodeAwarenessUpdate(this.awareness, [this.awareness.clientID])); 466 }; 467 468 channel.onmessage = e => { 469 const msg = new Uint8Array(e.data); 470 const payload = msg.slice(1); 471 if (msg[0] === MSG_DOC) Y.applyUpdate(this.#ydoc, payload, "webrtc"); 472 else if (msg[0] === MSG_AWARENESS) applyAwarenessUpdate(this.awareness, payload, "webrtc"); 473 }; 474 475 const onUpdate = (update, origin) => { 476 if (origin === "webrtc" || origin === this) return; 477 send(MSG_DOC, update); 478 }; 479 this.#ydoc.on("update", onUpdate); 480 481 const onAwarenessChange = ({ added, updated }) => { 482 const own = [...added, ...updated].filter(id => id === this.awareness.clientID); 483 if (own.length > 0) send(MSG_AWARENESS, encodeAwarenessUpdate(this.awareness, own)); 484 }; 485 this.awareness.on("change", onAwarenessChange); 486 487 channel.onclose = () => { 488 this.#ydoc.off("update", onUpdate); 489 this.awareness.off("change", onAwarenessChange); 490 this.#peers.delete(did); 491 this.onPeerDisconnect?.(did); 492 }; 493 } 494 495 async #handleOffer(did, record) { 496 if (this.#peers.has(did)) return; 497 498 const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); 499 this.#peers.set(did, { pc }); 500 pc.ondatachannel = e => this.#setupDataChannel(did, e.channel); 501 502 const icePromise = this.#waitForIce(pc); 503 await pc.setRemoteDescription({ type: "offer", sdp: record.sdp }); 504 const answer = await pc.createAnswer(); 505 await pc.setLocalDescription(answer); 506 await icePromise; 507 508 await this.#upsertRecord({ 509 repo: this.did, 510 collection: WEBRTC_COLLECTION, 511 record: { 512 target: did, 513 docId: this.#rkey, 514 type: "answer", 515 sdp: pc.localDescription.sdp, 516 createdAt: new Date().toISOString(), 517 }, 518 }); 519 } 520 521 async #handleAnswer(did, record) { 522 const peer = this.#peers.get(did); 523 if (!peer || peer.pc.signalingState !== "have-local-offer") return; 524 await peer.pc.setRemoteDescription({ type: "answer", sdp: record.sdp }); 525 } 526 527 async setMembers(editors) { 528 this.#doc = { ...this.#doc, editors }; 529 await this.#upsertRecord({ 530 repo: this.did, 531 collection: DOC_COLLECTION, 532 rkey: this.#rkey, 533 record: this.#doc, 534 }); 535 } 536 537 #subscribe() { 538 this.#closeSockets(); 539 for (const endpoint of this.#jetstreams) this.#connect(endpoint); 540 } 541 542 /** Close every socket without triggering its reconnect (intentional teardown). */ 543 #closeSockets() { 544 for (const ws of this.#sockets) { 545 ws.onclose = null; 546 ws.close(); 547 } 548 this.#sockets.clear(); 549 } 550 551 /** Record a commit key; returns false if it was already seen on another instance. */ 552 #once(key) { 553 if (this.#seen.has(key)) return false; 554 this.#seen.add(key); 555 this.#seenOrder.push(key); 556 if (this.#seenOrder.length > 2000) this.#seen.delete(this.#seenOrder.shift()); 557 return true; 558 } 559 560 #connect(endpoint) { 561 const url = new URL(endpoint); 562 url.searchParams.append("wantedCollections", UPDATE_COLLECTION); 563 url.searchParams.append("wantedCollections", AWARENESS_COLLECTION); 564 url.searchParams.append("wantedCollections", DOC_COLLECTION); 565 url.searchParams.append("wantedCollections", WEBRTC_COLLECTION); 566 url.searchParams.append("wantedDids", this.#ownerDid); 567 for (const editor of this.#doc.editors) url.searchParams.append("wantedDids", editor); 568 569 const cursor = this.#cursors.get(endpoint); 570 if (cursor) url.searchParams.set("cursor", String(cursor)); 571 572 const ws = new WebSocket(url); 573 this.#sockets.add(ws); 574 ws.onerror = () => {}; 575 ws.onclose = () => { 576 this.#sockets.delete(ws); 577 if (this.#destroyed) return; 578 setTimeout(() => { 579 if (!this.#destroyed) this.#connect(endpoint); 580 }, 1000); 581 }; 582 583 ws.onmessage = e => { 584 const event = JSON.parse(e.data); 585 this.#cursors.set(endpoint, event.time_us); 586 if (event.kind !== "commit") return; 587 588 // The same commit arrives from every instance; process it only once. 589 const c = event.commit; 590 if (!this.#once(`${event.did}/${c.collection}/${c.rkey}/${c.cid ?? c.operation}`)) return; 591 592 this.#handleCommit(event); 593 }; 594 } 595 596 async #handleCommit(event) { 597 switch (event.commit.collection) { 598 case DOC_COLLECTION: { 599 if (event.did !== this.#ownerDid || event.commit.rkey !== this.#rkey) return; 600 await this.load(); 601 break; 602 } 603 604 case UPDATE_COLLECTION: { 605 if (event.commit.record.docId !== this.#rkey) return; 606 if (event.did !== this.#ownerDid && !this.#doc.editors.includes(event.did)) return; 607 608 if (event.commit.operation !== "create") return; 609 610 if (this.#paused) break; 611 612 // Own writes are already tracked optimistically in #flushUpdate 613 if (event.did !== this.did) { 614 this.#allRecords.push({ 615 uri: `at://${event.did}/${UPDATE_COLLECTION}/${event.commit.rkey}`, 616 cid: event.commit.cid, 617 repo: event.did, 618 value: event.commit.record, 619 }); 620 this.onRecordAdded?.(); 621 } 622 Y.applyUpdate(this.#ydoc, decode(event.commit.record.update), this); 623 break; 624 } 625 626 case AWARENESS_COLLECTION: { 627 if (event.commit.record.docId !== this.#rkey) return; 628 if (event.did !== this.#ownerDid && !this.#doc.editors.includes(event.did)) return; 629 630 if (this.#paused) break; 631 632 this.#awarenessMap.set(event.did, { 633 repo: event.did, 634 uri: `at://${event.did}/${AWARENESS_COLLECTION}/${event.commit.rkey}`, 635 cid: event.commit.cid, 636 value: event.commit.record, 637 }); 638 this.onAwarenessChange?.(); 639 if (event.did === this.did) break; 640 applyAwarenessUpdate(this.awareness, decode(event.commit.record.awareness), "remote"); 641 break; 642 } 643 644 case WEBRTC_COLLECTION: { 645 const rec = event.commit.record; 646 if (rec.target !== this.did) return; 647 if (rec.docId !== this.#rkey) return; 648 if (event.did !== this.#ownerDid && !this.#doc.editors.includes(event.did)) return; 649 if (Date.now() - new Date(rec.createdAt).getTime() > 120_000) return; 650 651 if (rec.type === "offer") this.#handleOffer(event.did, rec).catch(console.error); 652 else if (rec.type === "answer") this.#handleAnswer(event.did, rec).catch(console.error); 653 break; 654 } 655 } 656 } 657 658 destroy() { 659 this.#destroyed = true; 660 this.#ydoc.off("update", this.#onUpdate); 661 this.awareness.off("change", this.#onAwarenessChange); 662 clearTimeout(this.#updateTimerId); 663 clearTimeout(this.#awarenessTimerId); 664 this.#flushUpdate(); 665 this.awareness.destroy(); 666 this.#closeSockets(); 667 for (const { pc } of this.#peers.values()) pc.close(); 668 this.#peers.clear(); 669 } 670}