a proof of concept realtime collaborative todo list
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}