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.

Save to PDS

+291 -23
+2
.gitignore
··· 1 + node_modules 2 + .DS_Store
+2 -2
callback.html example/callback.html
··· 17 17 const params = new URLSearchParams(location.hash.slice(1)); 18 18 19 19 finalizeAuthorization(params) 20 - .then(() => { 21 - console.log("/doc.html?id=" + crypto.randomUUID()); 20 + .then((agent) => { 21 + localStorage.setItem("ypds:did", agent.session.info.sub); 22 22 window.location.assign("/doc.html?id=" + crypto.randomUUID()); 23 23 }) 24 24 .catch((err) => console.error(err));
+3 -3
client-metadata.json example/client-metadata.json
··· 1 1 { 2 - "client_id": "https://8a49-66-108-106-210.ngrok-free.app/client-metadata.json", 3 - "client_uri": "https://8a49-66-108-106-210.ngrok-free.app", 4 - "redirect_uris": ["https://8a49-66-108-106-210.ngrok-free.app/callback.html"], 2 + "client_id": "https://1470-66-108-106-210.ngrok-free.app/client-metadata.json", 3 + "client_uri": "https://1470-66-108-106-210.ngrok-free.app", 4 + "redirect_uris": ["https://1470-66-108-106-210.ngrok-free.app/callback.html"], 5 5 "application_type": "native", 6 6 "client_name": "atrtc demo", 7 7 "dpop_bound_access_tokens": true,
+32 -7
doc.html example/doc.html
··· 59 59 import { EditorView } from "prosemirror-view"; 60 60 import { exampleSetup } from "prosemirror-example-setup"; 61 61 import * as Y from "yjs"; 62 - import { ySyncPlugin, yUndoPlugin, undo, redo } from "y-prosemirror"; 62 + import { ySyncPlugin, yUndoPlugin } from "y-prosemirror"; 63 + 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"); 74 + 75 + configure(); 76 + 77 + const did = localStorage.getItem("ypds:did"); 78 + const session = await getSession(did, { allowStale: false }); 79 + const rpc = client(session); 63 80 64 81 const ydoc = new Y.Doc(); 65 - const ytext = ydoc.getXmlFragment("prosemirror"); 82 + const yxml = ydoc.getXmlFragment("prosemirror"); 66 83 67 84 const state = EditorState.create({ 68 85 schema, 69 - plugins: [ 70 - ...exampleSetup({ schema, history: false }), 71 - ySyncPlugin(ytext), 72 - yUndoPlugin(), 73 - ], 86 + plugins: [...exampleSetup({ schema, history: false }), ySyncPlugin(yxml), yUndoPlugin()], 74 87 }); 75 88 76 89 const view = new EditorView(document.querySelector("#editor"), { state }); 90 + 91 + const provider = new YPdsProvider(ydoc, docId, { rpc, did }); 92 + await provider.load(); 77 93 </script> 94 + <style> 95 + #editor { 96 + padding: 0; 97 + } 98 + 99 + .ProseMirror-menubar-wrapper { 100 + height: 100%; 101 + } 102 + </style> 78 103 </head> 79 104 <body> 80 105 <div id="editor"></div>
+72
example/y-pds.js
··· 1 + /** @import { Client } from "@atcute/client"; */ 2 + import * as Y from "yjs"; 3 + 4 + const COLLECTION = "com.jakelazaroff.ypds.update"; 5 + 6 + const encode = (/** @type {Uint8Array} */ update) => btoa(String.fromCharCode(...update)); 7 + const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); 8 + 9 + export class YPdsProvider { 10 + /** @type {Y.Doc} */ 11 + #ydoc; 12 + 13 + #docId = ""; 14 + 15 + /** 16 + * @param {Y.Doc} ydoc 17 + * @param {string} docId 18 + * @param {{ rpc: Client, did: string }} options 19 + */ 20 + constructor(ydoc, docId, { rpc, did }) { 21 + this.#ydoc = ydoc; 22 + this.#docId = docId; 23 + this.rpc = rpc; 24 + this.did = did; 25 + } 26 + 27 + async load() { 28 + const records = []; 29 + let cursor; 30 + 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) 41 + .sort((a, b) => a.value.createdAt.localeCompare(b.value.createdAt)); 42 + 43 + Y.transact(this.#ydoc, () => { 44 + for (const { value } of updates) { 45 + Y.applyUpdate(this.#ydoc, decode(value.update)); 46 + } 47 + }); 48 + 49 + this.#ydoc.on("update", this.#onUpdate); 50 + } 51 + 52 + /** @param {Uint8Array} update */ 53 + #onUpdate = async update => { 54 + await this.rpc.post("com.atproto.repo.createRecord", { 55 + params: {}, 56 + input: { 57 + repo: this.did, 58 + collection: COLLECTION, 59 + record: { 60 + $type: COLLECTION, 61 + docId: this.#docId, 62 + update: encode(update), 63 + createdAt: new Date().toISOString(), 64 + }, 65 + }, 66 + }); 67 + }; 68 + 69 + destroy() { 70 + this.#ydoc.off("update", this.#onUpdate); 71 + } 72 + }
+14 -10
index.html example/index.html
··· 8 8 "imports": { 9 9 "@atcute/client": "https://esm.sh/@atcute/client", 10 10 "@atcute/identity-resolver": "https://esm.sh/@atcute/identity-resolver", 11 - "@atcute/oauth-browser-client": "https://esm.sh/@atcute/oauth-browser-client@3.0.0" 11 + "@atcute/oauth-browser-client": "https://esm.sh/@atcute/oauth-browser-client@3.0.0", 12 + "actor-typeahead": "https://esm.sh/actor-typeahead" 12 13 } 13 14 } 14 15 </script> 15 - </head> 16 - <body> 17 - <form> 18 - <input name="handle" /> 19 - <button>log in</button> 20 - </form> 21 16 <script type="module"> 17 + import "actor-typeahead"; 22 18 import { createAuthorizationUrl } from "@atcute/oauth-browser-client"; 23 19 24 20 import { configure, scope } from "./oauth.js"; 25 21 26 22 configure(); 27 23 28 - document.querySelector("form").onsubmit = async (e) => { 24 + document.querySelector("form").onsubmit = async e => { 29 25 e.preventDefault(); 30 26 const data = new FormData(e.currentTarget); 31 27 const identifier = data.get("handle"); ··· 36 32 scope, 37 33 }); 38 34 39 - // localStorage.setItem("did", identity.id); 40 - await new Promise((r) => setTimeout(r, 100)); 35 + await new Promise(r => setTimeout(r, 100)); 41 36 42 37 window.location.assign(authUrl); 43 38 }; 44 39 </script> 40 + <style></style> 41 + </head> 42 + <body> 43 + <form> 44 + <actor-typeahead> 45 + <input name="handle" /> 46 + </actor-typeahead> 47 + <button>log in</button> 48 + </form> 45 49 </body> 46 50 </html>
+8
jsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "esnext", 4 + "checkJs": true, 5 + "module": "esnext", 6 + "moduleResolution": "bundler" 7 + } 8 + }
+1 -1
oauth.js example/oauth.js
··· 27 27 /** @param {import("@atcute/oauth-browser-client").Session} */ 28 28 export function client(session) { 29 29 const handler = new OAuthUserAgent(session); 30 - const client = new Client({ handler }); 30 + return new Client({ handler }); 31 31 }
+147
package-lock.json
··· 1 + { 2 + "name": "y-pds", 3 + "lockfileVersion": 3, 4 + "requires": true, 5 + "packages": { 6 + "": { 7 + "devDependencies": { 8 + "@atcute/client": "^4.2.1", 9 + "yjs": "^13.6.30" 10 + } 11 + }, 12 + "node_modules/@atcute/client": { 13 + "version": "4.2.1", 14 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.2.1.tgz", 15 + "integrity": "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==", 16 + "dev": true, 17 + "license": "0BSD", 18 + "dependencies": { 19 + "@atcute/identity": "^1.1.3", 20 + "@atcute/lexicons": "^1.2.6" 21 + } 22 + }, 23 + "node_modules/@atcute/identity": { 24 + "version": "1.1.4", 25 + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.4.tgz", 26 + "integrity": "sha512-RCw1IqflfuSYCxK5m0lZCm0UnvIzcUnuhngiBhJEJb9a9Mc2SEf1xP3H8N5r8pvEH1LoAYd6/zrvCNU+uy9esw==", 27 + "dev": true, 28 + "license": "0BSD", 29 + "dependencies": { 30 + "@atcute/lexicons": "^1.2.9", 31 + "@badrap/valita": "^0.4.6" 32 + } 33 + }, 34 + "node_modules/@atcute/lexicons": { 35 + "version": "1.2.9", 36 + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.9.tgz", 37 + "integrity": "sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==", 38 + "dev": true, 39 + "license": "0BSD", 40 + "dependencies": { 41 + "@atcute/uint8array": "^1.1.1", 42 + "@atcute/util-text": "^1.1.1", 43 + "@standard-schema/spec": "^1.1.0", 44 + "esm-env": "^1.2.2" 45 + } 46 + }, 47 + "node_modules/@atcute/uint8array": { 48 + "version": "1.1.1", 49 + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.1.tgz", 50 + "integrity": "sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==", 51 + "dev": true, 52 + "license": "0BSD" 53 + }, 54 + "node_modules/@atcute/util-text": { 55 + "version": "1.2.0", 56 + "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.2.0.tgz", 57 + "integrity": "sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==", 58 + "dev": true, 59 + "license": "0BSD", 60 + "dependencies": { 61 + "unicode-segmenter": "^0.14.5" 62 + } 63 + }, 64 + "node_modules/@badrap/valita": { 65 + "version": "0.4.6", 66 + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz", 67 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==", 68 + "dev": true, 69 + "license": "MIT", 70 + "engines": { 71 + "node": ">= 18" 72 + } 73 + }, 74 + "node_modules/@standard-schema/spec": { 75 + "version": "1.1.0", 76 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", 77 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 78 + "dev": true, 79 + "license": "MIT" 80 + }, 81 + "node_modules/esm-env": { 82 + "version": "1.2.2", 83 + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 84 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 85 + "dev": true, 86 + "license": "MIT" 87 + }, 88 + "node_modules/isomorphic.js": { 89 + "version": "0.2.5", 90 + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 91 + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 92 + "dev": true, 93 + "license": "MIT", 94 + "funding": { 95 + "type": "GitHub Sponsors ❤", 96 + "url": "https://github.com/sponsors/dmonad" 97 + } 98 + }, 99 + "node_modules/lib0": { 100 + "version": "0.2.117", 101 + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", 102 + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", 103 + "dev": true, 104 + "license": "MIT", 105 + "dependencies": { 106 + "isomorphic.js": "^0.2.4" 107 + }, 108 + "bin": { 109 + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 110 + "0gentesthtml": "bin/gentesthtml.js", 111 + "0serve": "bin/0serve.js" 112 + }, 113 + "engines": { 114 + "node": ">=16" 115 + }, 116 + "funding": { 117 + "type": "GitHub Sponsors ❤", 118 + "url": "https://github.com/sponsors/dmonad" 119 + } 120 + }, 121 + "node_modules/unicode-segmenter": { 122 + "version": "0.14.5", 123 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 124 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 125 + "dev": true, 126 + "license": "MIT" 127 + }, 128 + "node_modules/yjs": { 129 + "version": "13.6.30", 130 + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", 131 + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", 132 + "dev": true, 133 + "license": "MIT", 134 + "dependencies": { 135 + "lib0": "^0.2.99" 136 + }, 137 + "engines": { 138 + "node": ">=16.0.0", 139 + "npm": ">=8.0.0" 140 + }, 141 + "funding": { 142 + "type": "GitHub Sponsors ❤", 143 + "url": "https://github.com/sponsors/dmonad" 144 + } 145 + } 146 + } 147 + }
+10
package.json
··· 1 + { 2 + "devDependencies": { 3 + "@atcute/client": "^4.2.1", 4 + "yjs": "^13.6.30" 5 + }, 6 + "prettier": { 7 + "arrowParens": "avoid", 8 + "printWidth": 100 9 + } 10 + }