a proof of concept realtime collaborative todo list
0

Configure Feed

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

atodo / atproto.js
4.9 kB 141 lines
1import { session } from "./state.js"; 2 3const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"; 4let lastTid = 0n; 5const clockId = BigInt(Math.floor(Math.random() * 1024)); 6 7export function tid() { 8 let ts = BigInt(Date.now()) * 1000n; 9 let n = (ts << 10n) | clockId; 10 if (n <= lastTid) n = lastTid + 1n; 11 lastTid = n; 12 let s = ""; 13 for (let i = 0; i < 13; i++) { 14 s = TID_ALPHABET[Number(n & 31n)] + s; 15 n >>= 5n; 16 } 17 return s; 18} 19 20const pdsCache = new Map(); 21export async function resolvePDS(did) { 22 if (pdsCache.has(did)) return pdsCache.get(did); 23 const url = did.startsWith("did:web:") 24 ? `https://${did.split(":")[2]}/.well-known/did.json` 25 : `https://plc.directory/${did}`; 26 const doc = await fetch(url).then((r) => r.json()); 27 const endpoint = doc.service?.find((s) => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 28 if (!endpoint) throw new Error(`No PDS for ${did}`); 29 pdsCache.set(did, endpoint); 30 return endpoint; 31} 32 33function sessionHeaders() { 34 return session.value ? { "x-atsw-did": session.value.did } : {}; 35} 36 37export async function listRecords(did, collection) { 38 const pds = await resolvePDS(did); 39 const records = []; 40 let cursor; 41 do { 42 const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); 43 url.searchParams.set("repo", did); 44 url.searchParams.set("collection", collection); 45 url.searchParams.set("limit", "100"); 46 if (cursor) url.searchParams.set("cursor", cursor); 47 const res = await fetch(url, { headers: sessionHeaders() }); 48 if (!res.ok) { 49 if (res.status === 400) return records; 50 throw new Error(`listRecords ${did}/${collection}: ${res.status}`); 51 } 52 const data = await res.json(); 53 records.push(...data.records); 54 cursor = data.cursor; 55 } while (cursor); 56 return records; 57} 58 59export async function getRecord(did, collection, rkey) { 60 const pds = await resolvePDS(did); 61 const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); 62 url.searchParams.set("repo", did); 63 url.searchParams.set("collection", collection); 64 url.searchParams.set("rkey", rkey); 65 const res = await fetch(url, { headers: sessionHeaders() }); 66 if (res.status === 400) return null; 67 if (!res.ok) throw new Error(`getRecord ${did}/${collection}/${rkey}: ${res.status}`); 68 return res.json(); 69} 70 71async function xrpcError(label, res) { 72 const text = await res.text(); 73 let body = null; 74 try { 75 body = JSON.parse(text); 76 } catch {} 77 const detail = body ? [body.error, body.message].filter(Boolean).join(": ") : text; 78 const err = new Error(`${label} ${res.status}: ${detail}`); 79 err.status = res.status; 80 err.atproto = body; 81 return err; 82} 83 84export function isSwapError(err) { 85 return err?.atproto?.error === "InvalidSwap"; 86} 87 88// `swap` semantics match com.atproto.repo.putRecord: 89// undefined → omit swapRecord (no check) 90// null → swapRecord: null (must not exist) 91// string → swapRecord: <cid> (must match) 92export async function putRecord(collection, rkey, record, swap) { 93 const s = session.value; 94 if (!s) throw new Error("Not logged in"); 95 const body = { repo: s.did, collection, rkey, record }; 96 if (swap !== undefined) body.swapRecord = swap; 97 const res = await fetch(`${s.pds}/xrpc/com.atproto.repo.putRecord`, { 98 method: "POST", 99 headers: { "content-type": "application/json", "x-atsw-did": s.did }, 100 body: JSON.stringify(body), 101 }); 102 if (!res.ok) throw await xrpcError("putRecord", res); 103 return res.json(); 104} 105 106export async function deleteRecord(collection, rkey, swap) { 107 const s = session.value; 108 if (!s) throw new Error("Not logged in"); 109 const body = { repo: s.did, collection, rkey }; 110 if (swap !== undefined) body.swapRecord = swap; 111 const res = await fetch(`${s.pds}/xrpc/com.atproto.repo.deleteRecord`, { 112 method: "POST", 113 headers: { "content-type": "application/json", "x-atsw-did": s.did }, 114 body: JSON.stringify(body), 115 }); 116 if (!res.ok) throw await xrpcError("deleteRecord", res); 117 return res.json(); 118} 119 120export async function resolveHandle(did) { 121 if (did.startsWith("did:web:")) return did.split(":").slice(2).join(":"); 122 try { 123 const doc = await fetch(`https://plc.directory/${did}`).then((r) => r.json()); 124 const aka = doc.alsoKnownAs?.find((u) => u.startsWith("at://")); 125 if (aka) return aka.slice("at://".length); 126 } catch {} 127 return did; 128} 129 130export async function resolveHandleToDid(handle) { 131 try { 132 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 133 const j = await r.json(); 134 const txt = j.Answer?.find((a) => a.data?.startsWith('"did=')); 135 if (txt) return txt.data.replace(/"/g, "").replace("did=", ""); 136 } catch {} 137 const r = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 138 const j = await r.json(); 139 if (!j.did) throw new Error(`Could not resolve ${handle}`); 140 return j.did; 141}