open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { errorMessage } from './error-format.js';
5
6const PLC_DIRECTORY = 'https://plc.directory';
7const pdsCache = new Map();
8
9function requestErrorMessage(method, url, err) {
10 const code = err?.cause?.code || err?.code;
11 if (code === 'ECONNREFUSED') return `could not connect to ${url} (refused)`;
12 if (code === 'ENOTFOUND') return `could not resolve ${url}`;
13 if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
14 return `timed out connecting to ${url}`;
15 }
16 return `request to ${url} failed: ${errorMessage(err)}`;
17}
18
19async function fetchJson(method, url) {
20 const requestUrl = url.toString();
21 try {
22 const res = await fetch(url);
23 if (!res.ok) throw new Error(`${method} ${requestUrl} returned ${res.status}`);
24 return await res.json();
25 } catch (err) {
26 if (err instanceof Error && err.message.startsWith(`${method} ${requestUrl} returned `)) {
27 throw err;
28 }
29 throw new Error(requestErrorMessage(method, requestUrl, err), { cause: err });
30 }
31}
32
33async function fetchDidDocument(did) {
34 let url;
35 if (did.startsWith('did:web:')) {
36 const rest = did.slice('did:web:'.length);
37 const parts = rest.split(':');
38 const domain = decodeURIComponent(parts[0]);
39 if (parts.length === 1) {
40 url = `https://${domain}/.well-known/did.json`;
41 } else {
42 url = `https://${domain}/${parts.slice(1).map(decodeURIComponent).join('/')}/did.json`;
43 }
44 } else {
45 url = `${PLC_DIRECTORY}/${did}`;
46 }
47
48 return fetchJson('GET', url);
49}
50
51export async function resolvePds(did) {
52 if (pdsCache.has(did)) return pdsCache.get(did);
53 const doc = await fetchDidDocument(did);
54 const pds = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer');
55 if (!pds?.serviceEndpoint) throw new Error(`no PDS found in DID document for ${did}`);
56 pdsCache.set(did, pds.serviceEndpoint);
57 return pds.serviceEndpoint;
58}
59
60export async function listRecordsFromPds(pdsUrl, repo, collection, limit) {
61 const records = [];
62 let cursor;
63 do {
64 const url = new URL('/xrpc/com.atproto.repo.listRecords', pdsUrl);
65 url.searchParams.set('repo', repo);
66 url.searchParams.set('collection', collection);
67 if (limit) url.searchParams.set('limit', String(limit));
68 if (cursor) url.searchParams.set('cursor', cursor);
69 const data = await fetchJson('GET', url);
70 records.push(...data.records);
71 cursor = data.cursor;
72 } while (cursor);
73 return { records };
74}
75
76export async function resolveHandleFromDid(did) {
77 try {
78 const doc = await fetchDidDocument(did);
79 const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
80 return aka ? aka.replace('at://', '') : did;
81 } catch (err) {
82 console.warn(`warning: failed to resolve handle for ${did}: ${errorMessage(err)}`);
83 return did;
84 }
85}
86
87export async function resolveHandle(handle) {
88 const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
89 const data = await fetchJson('GET', url);
90 return data.did;
91}
92
93export async function batchQuery(items, fn, { batchSize = 10, verbose = false } = {}) {
94 if (verbose) console.log(`[verbose] querying ${items.length} accounts in batches of ${batchSize}`);
95 const results = [];
96 for (let i = 0; i < items.length; i += batchSize) {
97 const chunk = items.slice(i, i + batchSize);
98 const settled = await Promise.allSettled(chunk.map(fn));
99 for (let j = 0; j < settled.length; j++) {
100 if (settled[j].status === 'fulfilled' && settled[j].value !== undefined) {
101 results.push(settled[j].value);
102 } else if (settled[j].status === 'rejected' && verbose) {
103 console.log(`[verbose] ${chunk[j]}: error: ${settled[j].reason?.message || settled[j].reason}`);
104 }
105 }
106 }
107 return results;
108}