the tangled network observatory ◈ every knot, its health, its version, in your terminal
tangled
cli
tui
knot
monitoring
observability
atproto
federation
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}