the tangled network observatory ◈ every knot, its health, its version, in your terminal
tangled cli tui knot monitoring observability atproto federation
0

Configure Feed

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

fid / src / api / relay.ts
4.1 kB 123 lines
1import { z } from "zod"; 2 3const RELAY = process.env.FID_RELAY ?? "https://relay1.us-west.bsky.network"; 4 5const DidPage = z.object({ 6 repos: z.array(z.object({ did: z.string() })), 7 cursor: z.string().optional(), 8}); 9 10// every account holding >=1 record in `collection` — the network IS the registry 11export async function listDidsByCollection( 12 collection: string, 13 onProgress?: (count: number) => void, 14): Promise<string[]> { 15 const dids: string[] = []; 16 let cursor: string | undefined; 17 do { 18 const url = new URL("/xrpc/com.atproto.sync.listReposByCollection", RELAY); 19 url.searchParams.set("collection", collection); 20 url.searchParams.set("limit", "1000"); 21 if (cursor) url.searchParams.set("cursor", cursor); 22 const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); 23 if (!res.ok) { 24 throw new Error( 25 `relay ${RELAY} answered ${res.status} for ${collection} — upstream is down, not the knots`, 26 ); 27 } 28 const page = DidPage.parse(await res.json()); 29 dids.push(...page.repos.map((r) => r.did)); 30 cursor = page.repos.length > 0 ? page.cursor : undefined; 31 onProgress?.(dids.length); 32 } while (cursor); 33 return dids; 34} 35 36const RecordsPage = z.object({ 37 records: z.array(z.object({ uri: z.string(), value: z.unknown() })), 38 cursor: z.string().optional(), 39}); 40 41type Record = { uri: string; value: unknown }; 42 43async function listRecords( 44 pds: string, 45 repo: string, 46 collection: string, 47): Promise<Record[]> { 48 const values: Record[] = []; 49 let cursor: string | undefined; 50 let pages = 0; 51 do { 52 const url = new URL("/xrpc/com.atproto.repo.listRecords", pds); 53 url.searchParams.set("repo", repo); 54 url.searchParams.set("collection", collection); 55 url.searchParams.set("limit", "100"); 56 if (cursor) url.searchParams.set("cursor", cursor); 57 let page: z.infer<typeof RecordsPage>; 58 try { 59 const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); 60 if (!res.ok) return values; 61 page = RecordsPage.parse(await res.json()); 62 } catch { 63 return values; // dead or garbage-spewing PDS: this owner is just unhydrated, not fatal 64 } 65 values.push(...page.records); 66 cursor = page.records.length > 0 ? page.cursor : undefined; 67 } while (cursor && ++pages < 50); // ponytail: page cap so a rogue PDS can't loop us forever 68 return values; 69} 70 71// service records (knots, spindles) share a shape: pre-~v1.x ones carry a host 72// field with a tid rkey; newer ones drop the field and the rkey IS the hostname 73// (at://did/sh.tangled.knot/knot.example.com, at://did/sh.tangled.spindle/spindle.example.com) 74const ServiceRecord = z.object({ host: z.string().min(1).optional() }); 75const RepoRecord = z.object({ 76 knot: z.string().min(1), 77 spindle: z.string().optional(), 78 description: z.string().optional(), 79 createdAt: z.string().optional(), 80}); 81 82export type RepoRef = { 83 name: string; 84 knot: string; 85 spindle?: string; 86 description?: string; 87 createdAt?: string; 88}; 89 90const host = (h: string) => h.trim().toLowerCase(); 91 92// hosts this owner has verified for a service collection (malformed records skipped, not fatal) 93export async function fetchServiceHosts( 94 pds: string, 95 did: string, 96 collection: "sh.tangled.knot" | "sh.tangled.spindle", 97): Promise<string[]> { 98 const records = await listRecords(pds, did, collection); 99 return records.flatMap((r) => { 100 const parsed = ServiceRecord.safeParse(r.value); 101 if (!parsed.success) return []; 102 const h = parsed.data.host ?? r.uri.split("/").pop() ?? ""; 103 return h.includes(".") || h.includes(":") ? [host(h)] : []; // tid rkey without host field = junk 104 }); 105} 106 107// this owner's repos — rkey is the repo name on tangled, display rkeys 108export async function fetchRepos(pds: string, did: string): Promise<RepoRef[]> { 109 const records = await listRecords(pds, did, "sh.tangled.repo"); 110 return records.flatMap((r) => { 111 const parsed = RepoRecord.safeParse(r.value); 112 if (!parsed.success) return []; 113 const name = r.uri.split("/").pop() ?? ""; 114 return [ 115 { 116 ...parsed.data, 117 knot: host(parsed.data.knot), 118 spindle: parsed.data.spindle ? host(parsed.data.spindle) : undefined, 119 name, 120 }, 121 ]; 122 }); 123}