Something went wrong. Try again.
atproto record helpers
Something went wrong. Try again.
tinyrecord tinyrecord.js
11 kB · 409 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410// This Source Code Form is subject to the terms of the Mozilla Public// License, v. 2.0. If a copy of the MPL was not distributed with this// file, You can obtain one at https://mozilla.org/MPL/2.0/.//// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinyrecord
/** * @template Input * @template {Input} Output * @typedef {{ "~standard": StandardSchemaProps<Input, Output> }} StandardSchema */
/** * @template Input * @template Output * @typedef {Object} StandardSchemaProps * @property {1} version * @property {string} vendor * @property {(value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>} validate * @property {{ input: Input, output: Output } | undefined} [types] */
/** * @template Output * @typedef {{ value: Output, issues?: undefined } | { issues: ReadonlyArray<{ message: string }> }} StandardSchemaResult */
/** * @template Output * @param {StandardSchema<unknown, Output>} schema * @param {unknown} value * @returns {Promise<Output>} */async function validate(schema, value) { const result = await schema["~standard"].validate(value); if (result.issues) { const messages = result.issues .map(/** @param {{ message: string }} i */ (i) => i.message) .join(", "); throw Object.assign(new Error(`Validation failed: ${messages}`), { issues: result.issues, }); } return result.value;}
/** * @typedef {Object} AtUri * @property {string} did * @property {string} collection * @property {string} rkey * @property {string} href */
/** * @param {string} uri * @returns {AtUri} */export function parseAtUri(uri) { if (!uri.startsWith("at://")) throw new Error(`Invalid AT URI: ${uri}`); const [did, collection, rkey] = uri.slice("at://".length).split("/"); if (!did || !collection || !rkey) throw new Error(`Invalid AT URI: ${uri}`); return { did, collection, rkey, href: uri };}
/** * @param {string} did * @param {string} collection * @param {string} rkey * @returns {AtUri} */export function atUri(did, collection, rkey) { return { did, collection, rkey, href: `at://${did}/${collection}/${rkey}` };}
// --- Resolution ---
/** @type {Map<string, Promise<{ did: string, pds: string }>>} */const resolutionCache = new Map();
/** * @param {string} handle * @param {typeof fetch} fetchFn * @returns {Promise<string>} */async function resolveHandle(handle, fetchFn) { const url = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; const res = await fetchFn(url); if (!res.ok) throw new Error(`Failed to resolve handle: ${handle}`); const { did } = await res.json(); return did;}
/** * @param {string} did * @param {typeof fetch} fetchFn * @returns {Promise<string>} */async function resolvePds(did, fetchFn) { /** @type {string} */ let docUrl; if (did.startsWith("did:plc:")) { docUrl = `https://plc.directory/${did}`; } else if (did.startsWith("did:web:")) { const host = did.slice("did:web:".length); docUrl = `https://${host}/.well-known/did.json`; } else { throw new Error(`Unsupported DID method: ${did}`); }
const res = await fetchFn(docUrl); if (!res.ok) throw new Error(`Failed to fetch DID doc for ${did}`); const doc = await res.json();
const service = doc.service?.find(/** @param {any} s */ (s) => s.id === "#atproto_pds"); if (!service) throw new Error(`No atproto_pds service in DID doc for ${did}`); return service.serviceEndpoint;}
/** * @param {string} actor * @param {typeof fetch} fetchFn * @returns {Promise<{ did: string, pds: string }>} */async function resolveActor(actor, fetchFn) { const did = actor.startsWith("did:") ? actor : await resolveHandle(actor, fetchFn); const pds = await resolvePds(did, fetchFn); return { did, pds };}
// --- FocusedCollection ---
/** * @template T * @typedef {Object} RepoRecord * @property {AtUri} uri * @property {string} cid * @property {T} value */
/** * @template T * @typedef {Object} ListPage * @property {RepoRecord<T>[]} records * @property {string | undefined} cursor */
/** * @typedef {Object} ListOptions * @property {number} [limit] * @property {string} [cursor] */
/** * @template T */class FocusedCollection { /** * @param {string} did * @param {string} pds * @param {string} nsid * @param {typeof fetch} fetchFn * @param {StandardSchema<unknown, T> | null} schema */ constructor(did, pds, nsid, fetchFn, schema) { this.did = did; this.pds = pds; this.nsid = nsid; this.fetchFn = fetchFn; this.schema = schema; }
/** * @param {string} path * @param {RequestInit} [init] */ async #rpc(path, init) { const res = await this.fetchFn(`${this.pds}/xrpc/${path}`, init); if (!res.ok) { const err = await res.json().catch(() => ({})); throw Object.assign(new Error(err.message ?? res.statusText), { status: res.status, error: err.error, }); } return res.json(); }
/** * @param {unknown} value * @returns {Promise<T>} */ async #validate(value) { return this.schema ? validate(this.schema, value) : /** @type {T} */ (value); }
/** * @param {{ uri: string, cid: string, value: unknown }} raw * @returns {Promise<RepoRecord<T>>} */ async #coerce(raw) { const value = await this.#validate(raw.value); return { uri: parseAtUri(raw.uri), cid: raw.cid, value }; }
/** * @param {T} record * @returns {Promise<T>} */ async #prepare(record) { return this.#validate(record); }
/** * @param {ListOptions} [opts] * @returns {Promise<ListPage<T>>} */ async list({ limit, cursor } = {}) { const params = new URLSearchParams({ repo: this.did, collection: this.nsid }); if (limit != null) params.set("limit", String(limit)); if (cursor != null) params.set("cursor", cursor); const page = await this.#rpc(`com.atproto.repo.listRecords?${params}`); const records = await Promise.all( page.records.map( /** @param {{ uri: string, cid: string, value: unknown }} r */ (r) => this.#coerce(r), ), ); return { records, cursor: page.cursor }; }
/** @returns {AsyncGenerator<RepoRecord<T>>} */ async *listAll() { let cursor; do { /** @type {ListPage<T>} */ const page = await this.list({ limit: 100, cursor }); yield* page.records; cursor = page.cursor; } while (cursor); }
/** * @param {string | AtUri} rkeyOrUri * @returns {Promise<RepoRecord<T>>} */ async get(rkeyOrUri) { const rkey = typeof rkeyOrUri === "string" && !rkeyOrUri.startsWith("at://") ? rkeyOrUri : parseAtUri(typeof rkeyOrUri === "string" ? rkeyOrUri : rkeyOrUri.href).rkey; const params = new URLSearchParams({ repo: this.did, collection: this.nsid, rkey }); const raw = await this.#rpc(`com.atproto.repo.getRecord?${params}`); return this.#coerce(raw); }
/** * @param {T} record * @returns {Promise<{ uri: AtUri, cid: string }>} */ async create(record) { const prepared = await this.#prepare(record); const raw = await this.#rpc("com.atproto.repo.createRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: this.did, collection: this.nsid, record: prepared }), }); return { uri: parseAtUri(raw.uri), cid: raw.cid }; }
/** * @param {string} rkey * @param {T} record * @param {string} [swapCid] * @returns {Promise<{ uri: AtUri, cid: string }>} */ async put(rkey, record, swapCid) { const prepared = await this.#prepare(record); const raw = await this.#rpc("com.atproto.repo.putRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: this.did, collection: this.nsid, rkey, record: prepared, ...(swapCid && { swapCid }), }), }); return { uri: parseAtUri(raw.uri), cid: raw.cid }; }
/** * @param {string} rkey * @param {string} [swapCid] * @returns {Promise<void>} */ async delete(rkey, swapCid) { await this.#rpc("com.atproto.repo.deleteRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo: this.did, collection: this.nsid, rkey, ...(swapCid && { swapCid }), }), }); }}
// --- Collection ---
/** * @template T */class Collection { /** * @param {string} nsid * @param {string} defaultActor * @param {typeof fetch} fetchFn * @param {StandardSchema<unknown, T> | null} schema */ constructor(nsid, defaultActor, fetchFn, schema) { this.nsid = nsid; this.defaultActor = defaultActor; this.fetchFn = fetchFn; this.schema = schema; }
/** * @param {string} actor * @returns {Promise<FocusedCollection<T>>} */ async of(actor) { let pending = resolutionCache.get(actor); if (!pending) { pending = resolveActor(actor, this.fetchFn); resolutionCache.set(actor, pending); } const { did, pds } = await pending; return new FocusedCollection(did, pds, this.nsid, this.fetchFn, this.schema); }
/** @returns {Promise<FocusedCollection<T>>} */ #self() { return this.of(this.defaultActor); }
/** @param {T} record */ async create(record) { return (await this.#self()).create(record); }
/** @param {string} rkey @param {T} record @param {string} [swapCid] */ async put(rkey, record, swapCid) { return (await this.#self()).put(rkey, record, swapCid); }
/** @param {string} rkey @param {string} [swapCid] */ async delete(rkey, swapCid) { return (await this.#self()).delete(rkey, swapCid); }
/** @param {string | AtUri} rkeyOrUri */ async get(rkeyOrUri) { return (await this.#self()).get(rkeyOrUri); }
/** @param {ListOptions} [opts] */ async list(opts) { return (await this.#self()).list(opts); }
async *listAll() { yield* (await this.#self()).listAll(); }}
// --- Repo ---
/** * @typedef {Object} RepoOptions * @property {string} actor - your DID or handle * @property {typeof fetch} [fetchFn] */
export class Repo { /** * @param {RepoOptions} options */ constructor({ actor, fetchFn = globalThis.fetch.bind(globalThis) }) { this.actor = actor; this.fetchFn = fetchFn;
if (!resolutionCache.has(actor)) { resolutionCache.set(actor, resolveActor(actor, fetchFn)); } }
/** * @template {StandardSchema<any, any>} S * @param {string} nsid * @param {S} [schema] * @returns {Collection<S extends StandardSchema<any, infer O> ? O : unknown>} */ collection(nsid, schema) { return new Collection(nsid, this.actor, this.fetchFn, schema ?? null); }}