Something went wrong. Try again.
Something went wrong. Try again.
5.2 kB · 160 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161import { type Account, updateTokens } from "./config.ts";
export class XrpcError extends Error { constructor( public status: number, public errorName: string | undefined, message: string, ) { super(message); this.name = "XrpcError"; }}
export interface CreateSessionResponse { did: string; handle: string; accessJwt: string; refreshJwt: string; didDoc?: { service?: { id: string; type: string; serviceEndpoint: string }[] };}
export async function createSession( pds: string, identifier: string, password: string,): Promise<CreateSessionResponse> { const resp = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identifier, password }), }); if (!resp.ok) { const body = (await resp.json().catch(() => ({}))) as { error?: string; message?: string }; throw new XrpcError(resp.status, body.error, body.message ?? `createSession failed (${resp.status})`); } return (await resp.json()) as CreateSessionResponse;}
interface RefreshResponse { accessJwt: string; refreshJwt: string; did: string; handle: string;}
async function refreshSession(pds: string, refreshJwt: string): Promise<RefreshResponse> { const resp = await fetch(`${pds}/xrpc/com.atproto.server.refreshSession`, { method: "POST", headers: { authorization: `Bearer ${refreshJwt}` }, }); if (!resp.ok) { const body = (await resp.json().catch(() => ({}))) as { error?: string; message?: string }; throw new XrpcError(resp.status, body.error, body.message ?? `refreshSession failed (${resp.status})`); } return (await resp.json()) as RefreshResponse;}
export class PdsClient { constructor(public account: Account) {}
async authedFetch(path: string, init: RequestInit = {}, retried = false): Promise<Response> { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${this.account.accessJwt}`); const resp = await fetch(`${this.account.pds}${path}`, { ...init, headers });
if (!retried && (await shouldRefresh(resp))) { const refreshed = await refreshSession(this.account.pds, this.account.refreshJwt); this.account.accessJwt = refreshed.accessJwt; this.account.refreshJwt = refreshed.refreshJwt; await updateTokens(this.account.did, refreshed.accessJwt, refreshed.refreshJwt); return this.authedFetch(path, init, true); } return resp; }
async query<T>(nsid: string, params: Record<string, string | undefined> = {}): Promise<T> { const search = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined) search.set(k, v); const qs = search.toString(); const resp = await this.authedFetch(`/xrpc/${nsid}${qs ? `?${qs}` : ""}`); return parseJson<T>(resp, nsid); }
async procedure<T>(nsid: string, body: unknown): Promise<T> { const resp = await this.authedFetch(`/xrpc/${nsid}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); return parseJson<T>(resp, nsid); }
// ---- Convenience wrappers around standard com.atproto endpoints ----
getServiceAuth(aud: string, lxm: string, expSeconds = 60): Promise<{ token: string }> { const exp = Math.floor(Date.now() / 1000) + expSeconds; return this.query("com.atproto.server.getServiceAuth", { aud, lxm, exp: String(exp), }); }
putRecord(input: { repo: string; collection: string; rkey: string; record: unknown; swapRecord?: string; validate?: boolean; }) { return this.procedure<{ uri: string; cid: string }>("com.atproto.repo.putRecord", input); }
getRecord(repo: string, collection: string, rkey: string) { return this.query<{ uri: string; cid: string; value: Record<string, unknown> }>( "com.atproto.repo.getRecord", { repo, collection, rkey }, ); }
listRecords(repo: string, collection: string, limit = 50, cursor?: string) { return this.query<{ cursor?: string; records: { uri: string; cid: string; value: Record<string, unknown> }[]; }>("com.atproto.repo.listRecords", { repo, collection, limit: String(limit), cursor, }); }}
async function shouldRefresh(resp: Response): Promise<boolean> { if (resp.status === 401) return true; if (resp.status !== 400) return false; // atproto returns 400 with {"error":"ExpiredToken",...} for expired access // JWTs. Peek at the body without consuming it (we clone here so the caller // can still read the original response if no retry happens). try { const clone = resp.clone(); const body = (await clone.json()) as { error?: string }; return body.error === "ExpiredToken"; } catch { return false; }}
async function parseJson<T>(resp: Response, nsid: string): Promise<T> { if (!resp.ok) { const body = (await resp.json().catch(() => ({}))) as { error?: string; message?: string }; throw new XrpcError( resp.status, body.error, body.message ?? `${nsid} failed (${resp.status})`, ); } return (await resp.json()) as T;}