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

Configure Feed

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

Add collaboration

+239 -35
+135 -12
example/doc.html
··· 45 45 "prosemirror-schema-basic": "https://esm.sh/prosemirror-schema-basic", 46 46 "prosemirror-example-setup": "https://esm.sh/prosemirror-example-setup", 47 47 "yjs": "https://esm.sh/yjs", 48 - "y-prosemirror": "https://esm.sh/y-prosemirror" 48 + "y-prosemirror": "https://esm.sh/y-prosemirror", 49 + "actor-typeahead": "https://esm.sh/actor-typeahead" 49 50 } 50 51 } 51 52 </script> ··· 61 62 import * as Y from "yjs"; 62 63 import { ySyncPlugin, yUndoPlugin } from "y-prosemirror"; 63 64 import { getSession } from "@atcute/oauth-browser-client"; 64 - import { configure, client } from "./oauth.js"; 65 - import { YPdsProvider } from "./y-pds.js"; 66 - 67 - // doc id 68 - const params = new URLSearchParams(location.search); 69 - if (!params.has("id")) { 70 - params.set("id", crypto.randomUUID()); 71 - history.replaceState(null, "", "?" + params); 72 - } 73 - const docId = params.get("id"); 65 + import { configure, client, resolve } from "./oauth.js"; 66 + import { YPdsProvider, newDocUri } from "./y-pds.js"; 67 + import "actor-typeahead"; 74 68 75 69 configure(); 76 70 ··· 78 72 const session = await getSession(did, { allowStale: false }); 79 73 const rpc = client(session); 80 74 75 + const params = new URLSearchParams(location.search); 76 + if (!params.has("id")) { 77 + params.set("id", newDocUri(did)); 78 + history.replaceState(null, "", "?" + params); 79 + } 80 + const atUri = params.get("id"); 81 + 81 82 const ydoc = new Y.Doc(); 82 83 const yxml = ydoc.getXmlFragment("prosemirror"); 83 84 ··· 88 89 89 90 const view = new EditorView(document.querySelector("#editor"), { state }); 90 91 91 - const provider = new YPdsProvider(ydoc, docId, { rpc, did }); 92 + const provider = new YPdsProvider(ydoc, atUri, { rpc, did }); 92 93 await provider.load(); 94 + 95 + const shareBtn = document.querySelector("#share"); 96 + const shareDialog = document.querySelector("#share-dialog"); 97 + const membersList = document.querySelector("#members"); 98 + const addMemberForm = document.querySelector("#add-member"); 99 + 100 + shareBtn.addEventListener("click", async () => { 101 + const doc = await provider.getDocument(); 102 + renderMembers(doc.members); 103 + shareDialog.showModal(); 104 + }); 105 + 106 + function renderMembers(members) { 107 + membersList.innerHTML = ""; 108 + for (const memberDid of members) { 109 + const li = document.createElement("li"); 110 + const span = document.createElement("span"); 111 + span.textContent = memberDid; 112 + const removeBtn = document.createElement("button"); 113 + removeBtn.type = "button"; 114 + removeBtn.textContent = "Remove"; 115 + removeBtn.addEventListener("click", async () => { 116 + const updated = members.filter(m => m !== memberDid); 117 + await provider.updateMembers(updated); 118 + renderMembers(updated); 119 + }); 120 + li.append(span, removeBtn); 121 + membersList.append(li); 122 + } 123 + } 124 + 125 + addMemberForm.addEventListener("submit", async e => { 126 + e.preventDefault(); 127 + const identifier = addMemberForm.did.value.trim(); 128 + if (!identifier) return; 129 + const newDid = await resolve(identifier); 130 + const doc = await provider.getDocument(); 131 + if (doc.members.includes(newDid)) return; 132 + const updated = [...doc.members, newDid]; 133 + await provider.updateMembers(updated); 134 + renderMembers(updated); 135 + addMemberForm.reset(); 136 + }); 137 + 138 + document.querySelector("#close-dialog").addEventListener("click", () => { 139 + shareDialog.close(); 140 + }); 93 141 </script> 94 142 <style> 95 143 #editor { ··· 99 147 .ProseMirror-menubar-wrapper { 100 148 height: 100%; 101 149 } 150 + 151 + #share { 152 + position: fixed; 153 + top: 0.6rem; 154 + right: 0.75rem; 155 + z-index: 10; 156 + } 157 + 158 + #share-dialog { 159 + min-width: 320px; 160 + padding: 1.5rem; 161 + border: 1px solid #ccc; 162 + border-radius: 8px; 163 + } 164 + 165 + #share-dialog h2 { 166 + margin-bottom: 1rem; 167 + font-size: 1rem; 168 + } 169 + 170 + #members { 171 + list-style: none; 172 + margin-bottom: 1rem; 173 + } 174 + 175 + #members li { 176 + display: flex; 177 + align-items: center; 178 + gap: 0.5rem; 179 + padding: 0.25rem 0; 180 + } 181 + 182 + #members li span { 183 + flex: 1; 184 + font-size: 0.875rem; 185 + font-family: monospace; 186 + overflow: hidden; 187 + text-overflow: ellipsis; 188 + } 189 + 190 + #add-member { 191 + display: flex; 192 + gap: 0.5rem; 193 + margin-bottom: 1rem; 194 + } 195 + 196 + #add-member input { 197 + flex: 1; 198 + font: inherit; 199 + font-size: 0.875rem; 200 + padding: 0.25rem 0.5rem; 201 + border: 1px solid #ccc; 202 + border-radius: 4px; 203 + } 204 + 205 + #share-dialog button { 206 + font: inherit; 207 + font-size: 0.875rem; 208 + padding: 0.25rem 0.75rem; 209 + border: 1px solid #ccc; 210 + border-radius: 4px; 211 + cursor: pointer; 212 + } 102 213 </style> 103 214 </head> 104 215 <body> 105 216 <div id="editor"></div> 217 + <button id="share">Share</button> 218 + <dialog id="share-dialog"> 219 + <h2>Share</h2> 220 + <ul id="members"></ul> 221 + <form id="add-member"> 222 + <actor-typeahead> 223 + <input name="did" placeholder="Search for a user…" autocomplete="off" /> 224 + </actor-typeahead> 225 + <button type="submit">Add</button> 226 + </form> 227 + <button id="close-dialog">Close</button> 228 + </dialog> 106 229 </body> 107 230 </html>
+14 -6
example/oauth.js
··· 12 12 XrpcHandleResolver, 13 13 } from "@atcute/identity-resolver"; 14 14 15 + const resolver = new LocalActorResolver({ 16 + handleResolver: new XrpcHandleResolver({ serviceUrl: "https://public.api.bsky.app" }), 17 + didDocumentResolver: new CompositeDidDocumentResolver({ 18 + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, 19 + }), 20 + }); 21 + 15 22 export function configure() { 16 23 configureOAuth({ 17 24 metadata: { client_id: metadata.client_id, redirect_uri: metadata.redirect_uris[0] }, 18 - identityResolver: new LocalActorResolver({ 19 - handleResolver: new XrpcHandleResolver({ serviceUrl: "https://public.api.bsky.app" }), 20 - didDocumentResolver: new CompositeDidDocumentResolver({ 21 - methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, 22 - }), 23 - }), 25 + identityResolver: resolver, 24 26 }); 27 + } 28 + 29 + /** @param {string} handleOrDid */ 30 + export async function resolve(handleOrDid) { 31 + const { did } = await resolver.resolve(handleOrDid); 32 + return did; 25 33 } 26 34 27 35 /** @param {import("@atcute/oauth-browser-client").Session} */
+90 -17
example/y-pds.js
··· 1 1 /** @import { Client } from "@atcute/client"; */ 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 3 import * as Y from "yjs"; 3 4 5 + /** @param {string} did */ 6 + async function clientForDid(did) { 7 + const url = did.startsWith("did:web:") 8 + ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 9 + : `https://plc.directory/${did}`; 10 + const doc = await fetch(url).then(r => r.json()); 11 + const pds = doc.service?.find(s => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 12 + if (!pds) throw new Error(`no PDS found for ${did}`); 13 + return new Client({ handler: simpleFetchHandler({ service: pds }) }); 14 + } 15 + 4 16 const COLLECTION = "com.jakelazaroff.ypds.update"; 17 + const DOC_COLLECTION = "com.jakelazaroff.ypds.doc"; 5 18 6 19 const encode = (/** @type {Uint8Array} */ update) => btoa(String.fromCharCode(...update)); 7 20 const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); 8 21 22 + /** @param {string} did */ 23 + export function newDocUri(did) { 24 + return `at://${did}/${DOC_COLLECTION}/${crypto.randomUUID()}`; 25 + } 26 + 9 27 export class YPdsProvider { 10 28 /** @type {Y.Doc} */ 11 29 #ydoc; 12 30 13 - #docId = ""; 31 + #ownerDid = ""; 32 + #rkey = ""; 14 33 15 34 /** 16 35 * @param {Y.Doc} ydoc 17 - * @param {string} docId 36 + * @param {string} atUri at://ownerDid/collection/rkey 18 37 * @param {{ rpc: Client, did: string }} options 19 38 */ 20 - constructor(ydoc, docId, { rpc, did }) { 39 + constructor(ydoc, atUri, { rpc, did }) { 21 40 this.#ydoc = ydoc; 22 - this.#docId = docId; 41 + const [ownerDid, , rkey] = atUri.slice("at://".length).split("/"); 42 + this.#ownerDid = ownerDid; 43 + this.#rkey = rkey; 23 44 this.rpc = rpc; 24 45 this.did = did; 25 46 } 26 47 27 48 async load() { 28 - const records = []; 29 - let cursor; 49 + const doc = await this.#ensureDocument(); 50 + const repos = [this.#ownerDid, ...doc.members]; 30 51 31 - do { 32 - const res = await this.rpc.get("com.atproto.repo.listRecords", { 33 - params: { repo: this.did, collection: COLLECTION, limit: 100, cursor }, 34 - }); 35 - records.push(...res.data.records); 36 - cursor = res.data.cursor; 37 - } while (cursor); 38 - 39 - const updates = records 40 - .filter(r => r.value.docId === this.#docId) 52 + const perRepo = await Promise.all(repos.map(repo => this.#fetchUpdates(repo))); 53 + const updates = perRepo 54 + .flat() 41 55 .sort((a, b) => a.value.createdAt.localeCompare(b.value.createdAt)); 42 56 43 57 Y.transact(this.#ydoc, () => { ··· 49 63 this.#ydoc.on("update", this.#onUpdate); 50 64 } 51 65 66 + async #fetchUpdates(repo) { 67 + const rpc = repo === this.did ? this.rpc : await clientForDid(repo); 68 + const records = []; 69 + let cursor; 70 + 71 + do { 72 + const res = await rpc.get("com.atproto.repo.listRecords", { 73 + params: { repo, collection: COLLECTION, limit: 100, cursor }, 74 + }); 75 + records.push(...res.data.records); 76 + cursor = res.data.cursor; 77 + } while (cursor); 78 + 79 + return records.filter(r => r.value.docId === this.#rkey); 80 + } 81 + 52 82 /** @param {Uint8Array} update */ 53 83 #onUpdate = async update => { 54 84 await this.rpc.post("com.atproto.repo.createRecord", { ··· 58 88 collection: COLLECTION, 59 89 record: { 60 90 $type: COLLECTION, 61 - docId: this.#docId, 91 + docId: this.#rkey, 62 92 update: encode(update), 63 93 createdAt: new Date().toISOString(), 64 94 }, 65 95 }, 66 96 }); 67 97 }; 98 + 99 + async #ensureDocument() { 100 + const rpc = this.#ownerDid === this.did ? this.rpc : await clientForDid(this.#ownerDid); 101 + const res = await rpc.get("com.atproto.repo.getRecord", { 102 + params: { repo: this.#ownerDid, collection: DOC_COLLECTION, rkey: this.#rkey }, 103 + }); 104 + 105 + if (res.ok) return res.data.value; 106 + if (this.did !== this.#ownerDid) throw new Error("document not found"); 107 + 108 + const record = { 109 + $type: DOC_COLLECTION, 110 + docId: this.#rkey, 111 + members: [], 112 + createdAt: new Date().toISOString(), 113 + }; 114 + await this.rpc.post("com.atproto.repo.putRecord", { 115 + params: {}, 116 + input: { repo: this.did, collection: DOC_COLLECTION, rkey: this.#rkey, record }, 117 + }); 118 + return record; 119 + } 120 + 121 + async getDocument() { 122 + const rpc = this.#ownerDid === this.did ? this.rpc : await clientForDid(this.#ownerDid); 123 + const res = await rpc.get("com.atproto.repo.getRecord", { 124 + params: { repo: this.#ownerDid, collection: DOC_COLLECTION, rkey: this.#rkey }, 125 + }); 126 + if (!res.ok) throw new Error("document not found"); 127 + return res.data.value; 128 + } 129 + 130 + async updateMembers(members) { 131 + const doc = await this.getDocument(); 132 + await this.rpc.post("com.atproto.repo.putRecord", { 133 + input: { 134 + repo: this.did, 135 + collection: DOC_COLLECTION, 136 + rkey: this.#rkey, 137 + record: { ...doc, members }, 138 + }, 139 + }); 140 + } 68 141 69 142 destroy() { 70 143 this.#ydoc.off("update", this.#onUpdate);