atproto record helpers
0

Configure Feed

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

tinyrecord / tinyrecord.js
11 kB 406 lines
1// This Source Code Form is subject to the terms of the Mozilla Public 2// License, v. 2.0. If a copy of the MPL was not distributed with this 3// file, You can obtain one at https://mozilla.org/MPL/2.0/. 4// 5// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinyrecord 6 7/** 8 * @template Input 9 * @template {Input} Output 10 * @typedef {{ "~standard": StandardSchemaProps<Input, Output> }} StandardSchema 11 */ 12 13/** 14 * @template Input 15 * @template Output 16 * @typedef {Object} StandardSchemaProps 17 * @property {1} version 18 * @property {string} vendor 19 * @property {(value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>} validate 20 * @property {{ input: Input, output: Output } | undefined} [types] 21 */ 22 23/** 24 * @template Output 25 * @typedef {{ value: Output, issues?: undefined } | { issues: ReadonlyArray<{ message: string }> }} StandardSchemaResult 26 */ 27 28/** 29 * @template Output 30 * @param {StandardSchema<unknown, Output>} schema 31 * @param {unknown} value 32 * @returns {Promise<Output>} 33 */ 34async function validate(schema, value) { 35 const result = await schema["~standard"].validate(value); 36 if (result.issues) { 37 const messages = result.issues 38 .map(/** @param {{ message: string }} i */ (i) => i.message) 39 .join(", "); 40 throw Object.assign(new Error(`Validation failed: ${messages}`), { 41 issues: result.issues, 42 }); 43 } 44 return result.value; 45} 46 47/** 48 * @typedef {Object} AtUri 49 * @property {string} did 50 * @property {string} collection 51 * @property {string} rkey 52 * @property {string} href 53 */ 54 55/** 56 * @param {string} uri 57 * @returns {AtUri} 58 */ 59export function parseAtUri(uri) { 60 if (!uri.startsWith("at://")) throw new Error(`Invalid AT URI: ${uri}`); 61 const [did, collection, rkey] = uri.slice("at://".length).split("/"); 62 if (!did || !collection || !rkey) throw new Error(`Invalid AT URI: ${uri}`); 63 return { did, collection, rkey, href: uri }; 64} 65 66/** 67 * @param {string} did 68 * @param {string} collection 69 * @param {string} rkey 70 * @returns {AtUri} 71 */ 72export function atUri(did, collection, rkey) { 73 return { did, collection, rkey, href: `at://${did}/${collection}/${rkey}` }; 74} 75 76// --- Resolution --- 77 78/** @type {Map<string, Promise<{ did: string, pds: string }>>} */ 79const resolutionCache = new Map(); 80 81/** 82 * @param {string} handle 83 * @param {typeof fetch} fetchFn 84 * @returns {Promise<string>} 85 */ 86async function resolveHandle(handle, fetchFn) { 87 const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 88 const res = await fetchFn(url); 89 if (!res.ok) throw new Error(`Failed to resolve handle: ${handle}`); 90 const { did } = await res.json(); 91 return did; 92} 93 94/** 95 * @param {string} did 96 * @param {typeof fetch} fetchFn 97 * @returns {Promise<string>} 98 */ 99async function resolvePds(did, fetchFn) { 100 /** @type {string} */ 101 let docUrl; 102 if (did.startsWith("did:plc:")) { 103 docUrl = `https://plc.directory/${did}`; 104 } else if (did.startsWith("did:web:")) { 105 const host = did.slice("did:web:".length); 106 docUrl = `https://${host}/.well-known/did.json`; 107 } else { 108 throw new Error(`Unsupported DID method: ${did}`); 109 } 110 111 const res = await fetchFn(docUrl); 112 if (!res.ok) throw new Error(`Failed to fetch DID doc for ${did}`); 113 const doc = await res.json(); 114 115 const service = doc.service?.find(/** @param {any} s */ (s) => s.id === "#atproto_pds"); 116 if (!service) throw new Error(`No atproto_pds service in DID doc for ${did}`); 117 return service.serviceEndpoint; 118} 119 120/** 121 * @param {string} actor 122 * @param {typeof fetch} fetchFn 123 * @returns {Promise<{ did: string, pds: string }>} 124 */ 125async function resolveActor(actor, fetchFn) { 126 const did = actor.startsWith("did:") ? actor : await resolveHandle(actor, fetchFn); 127 const pds = await resolvePds(did, fetchFn); 128 return { did, pds }; 129} 130 131// --- FocusedCollection --- 132 133/** 134 * @template T 135 * @typedef {Object} RepoRecord 136 * @property {AtUri} uri 137 * @property {string} cid 138 * @property {T} value 139 */ 140 141/** 142 * @template T 143 * @typedef {Object} ListPage 144 * @property {RepoRecord<T>[]} records 145 * @property {string | undefined} cursor 146 */ 147 148/** 149 * @typedef {Object} ListOptions 150 * @property {number} [limit] 151 * @property {string} [cursor] 152 */ 153 154/** 155 * @template T 156 */ 157class FocusedCollection { 158 /** 159 * @param {string} did 160 * @param {string} pds 161 * @param {string} nsid 162 * @param {typeof fetch} fetchFn 163 * @param {StandardSchema<unknown, T> | null} schema 164 */ 165 constructor(did, pds, nsid, fetchFn, schema) { 166 this.did = did; 167 this.pds = pds; 168 this.nsid = nsid; 169 this.fetchFn = fetchFn; 170 this.schema = schema; 171 } 172 173 /** 174 * @param {string} path 175 * @param {RequestInit} [init] 176 */ 177 async #rpc(path, init) { 178 const res = await this.fetchFn(`${this.pds}/xrpc/${path}`, init); 179 if (!res.ok) { 180 const err = await res.json().catch(() => ({})); 181 throw Object.assign(new Error(err.message ?? res.statusText), { 182 status: res.status, 183 error: err.error, 184 }); 185 } 186 return res.json(); 187 } 188 189 /** 190 * @param {unknown} value 191 * @returns {Promise<T>} 192 */ 193 async #validate(value) { 194 return this.schema ? validate(this.schema, value) : /** @type {T} */ (value); 195 } 196 197 /** 198 * @param {{ uri: string, cid: string, value: unknown }} raw 199 * @returns {Promise<RepoRecord<T>>} 200 */ 201 async #coerce(raw) { 202 const value = await this.#validate(raw.value); 203 return { uri: parseAtUri(raw.uri), cid: raw.cid, value }; 204 } 205 206 /** 207 * @param {T} record 208 * @returns {Promise<T>} 209 */ 210 async #prepare(record) { 211 return this.#validate(record); 212 } 213 214 /** 215 * @param {ListOptions} [opts] 216 * @returns {Promise<ListPage<T>>} 217 */ 218 async list({ limit, cursor } = {}) { 219 const params = new URLSearchParams({ repo: this.did, collection: this.nsid }); 220 if (limit != null) params.set("limit", String(limit)); 221 if (cursor != null) params.set("cursor", cursor); 222 const page = await this.#rpc(`com.atproto.repo.listRecords?${params}`); 223 const records = await Promise.all( 224 page.records.map( 225 /** @param {{ uri: string, cid: string, value: unknown }} r */ (r) => this.#coerce(r), 226 ), 227 ); 228 return { records, cursor: page.cursor }; 229 } 230 231 /** @returns {AsyncGenerator<RepoRecord<T>>} */ 232 async *listAll() { 233 let cursor; 234 do { 235 /** @type {ListPage<T>} */ 236 const page = await this.list({ limit: 100, cursor }); 237 yield* page.records; 238 cursor = page.cursor; 239 } while (cursor); 240 } 241 242 /** 243 * @param {string | AtUri} rkeyOrUri 244 * @returns {Promise<RepoRecord<T>>} 245 */ 246 async get(rkeyOrUri) { 247 const rkey = 248 typeof rkeyOrUri === "string" && !rkeyOrUri.startsWith("at://") 249 ? rkeyOrUri 250 : parseAtUri(typeof rkeyOrUri === "string" ? rkeyOrUri : rkeyOrUri.href).rkey; 251 const params = new URLSearchParams({ repo: this.did, collection: this.nsid, rkey }); 252 const raw = await this.#rpc(`com.atproto.repo.getRecord?${params}`); 253 return this.#coerce(raw); 254 } 255 256 /** 257 * @param {T} record 258 * @returns {Promise<{ uri: AtUri, cid: string }>} 259 */ 260 async create(record) { 261 const prepared = await this.#prepare(record); 262 const raw = await this.#rpc("com.atproto.repo.createRecord", { 263 method: "POST", 264 headers: { "Content-Type": "application/json" }, 265 body: JSON.stringify({ repo: this.did, collection: this.nsid, record: prepared }), 266 }); 267 return { uri: parseAtUri(raw.uri), cid: raw.cid }; 268 } 269 270 /** 271 * @param {string} rkey 272 * @param {T} record 273 * @param {string} [swapCid] 274 * @returns {Promise<{ uri: AtUri, cid: string }>} 275 */ 276 async put(rkey, record, swapCid) { 277 const prepared = await this.#prepare(record); 278 const raw = await this.#rpc("com.atproto.repo.putRecord", { 279 method: "POST", 280 headers: { "Content-Type": "application/json" }, 281 body: JSON.stringify({ 282 repo: this.did, 283 collection: this.nsid, 284 rkey, 285 record: prepared, 286 ...(swapCid && { swapCid }), 287 }), 288 }); 289 return { uri: parseAtUri(raw.uri), cid: raw.cid }; 290 } 291 292 /** 293 * @param {string} rkey 294 * @param {string} [swapCid] 295 * @returns {Promise<void>} 296 */ 297 async delete(rkey, swapCid) { 298 await this.#rpc("com.atproto.repo.deleteRecord", { 299 method: "POST", 300 headers: { "Content-Type": "application/json" }, 301 body: JSON.stringify({ 302 repo: this.did, 303 collection: this.nsid, 304 rkey, 305 ...(swapCid && { swapCid }), 306 }), 307 }); 308 } 309} 310 311// --- Collection --- 312 313/** 314 * @template T 315 */ 316class Collection { 317 /** 318 * @param {string} nsid 319 * @param {string} defaultActor 320 * @param {typeof fetch} fetchFn 321 * @param {StandardSchema<unknown, T> | null} schema 322 */ 323 constructor(nsid, defaultActor, fetchFn, schema) { 324 this.nsid = nsid; 325 this.defaultActor = defaultActor; 326 this.fetchFn = fetchFn; 327 this.schema = schema; 328 } 329 330 /** 331 * @param {string} actor 332 * @returns {Promise<FocusedCollection<T>>} 333 */ 334 async of(actor) { 335 let pending = resolutionCache.get(actor); 336 if (!pending) { 337 pending = resolveActor(actor, this.fetchFn); 338 resolutionCache.set(actor, pending); 339 } 340 const { did, pds } = await pending; 341 return new FocusedCollection(did, pds, this.nsid, this.fetchFn, this.schema); 342 } 343 344 /** @returns {Promise<FocusedCollection<T>>} */ 345 #self() { 346 return this.of(this.defaultActor); 347 } 348 349 /** @param {T} record */ 350 async create(record) { 351 return (await this.#self()).create(record); 352 } 353 354 /** @param {string} rkey @param {T} record @param {string} [swapCid] */ 355 async put(rkey, record, swapCid) { 356 return (await this.#self()).put(rkey, record, swapCid); 357 } 358 359 /** @param {string} rkey @param {string} [swapCid] */ 360 async delete(rkey, swapCid) { 361 return (await this.#self()).delete(rkey, swapCid); 362 } 363 364 /** @param {string | AtUri} rkeyOrUri */ 365 async get(rkeyOrUri) { 366 return (await this.#self()).get(rkeyOrUri); 367 } 368 369 /** @param {ListOptions} [opts] */ 370 async list(opts) { 371 return (await this.#self()).list(opts); 372 } 373 374 async *listAll() { 375 yield* (await this.#self()).listAll(); 376 } 377} 378 379// --- createRepo --- 380 381/** 382 * @typedef {Object} RepoOptions 383 * @property {string} actor - your DID or handle 384 * @property {typeof fetch} [fetchFn] 385 */ 386 387/** 388 * @param {RepoOptions} options 389 */ 390export function createRepo({ actor, fetchFn = globalThis.fetch.bind(globalThis) }) { 391 if (!resolutionCache.has(actor)) { 392 resolutionCache.set(actor, resolveActor(actor, fetchFn)); 393 } 394 395 /** 396 * @template {StandardSchema<any, any>} S 397 * @param {string} nsid 398 * @param {S} [schema] 399 * @returns {Collection<S extends StandardSchema<any, infer O> ? O : unknown>} 400 */ 401 function collection(nsid, schema) { 402 return new Collection(nsid, actor, fetchFn, schema ?? null); 403 } 404 405 return { collection }; 406}