A TypeScript toolkit for consuming the Bluesky network in real-time.
1// Thread Explorer
2// Fetches a Bluesky post thread and renders it as an indented tree.
3// Accepts a bsky.app URL or an AT URI.
4// Demonstrates: getPostThread, parseAtUri, atUriToBskyUrl, formatPost, toBskyUrl.
5//
6// Usage: npx tsx examples/thread-explorer.ts <post-url-or-at-uri>
7// Examples:
8// npx tsx examples/thread-explorer.ts https://bsky.app/profile/jay.bsky.team/post/3mffccqrpx22t
9// npx tsx examples/thread-explorer.ts at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3mffccqrpx22t
10
11import {
12 fetchGetPostThread,
13 fetchResolveHandle,
14 parseAtUri,
15 buildAtUri,
16 parseBskyUrl,
17 extractPostViewInfo,
18 formatEngagement,
19} from "../lib/index.js";
20
21// --- Config ---
22
23const input = process.argv[2];
24
25if (!input) {
26 console.error("Usage: npx tsx examples/thread-explorer.ts <post-url-or-at-uri>");
27 console.error("Example: npx tsx examples/thread-explorer.ts https://bsky.app/profile/jay.bsky.team/post/3mffccqrpx22t");
28 process.exit(1);
29}
30
31// --- URL parsing ---
32
33const resolveDid = async (idOrHandle: string): Promise<string> =>
34 idOrHandle.startsWith("did:") ? idOrHandle : fetchResolveHandle(idOrHandle);
35
36const resolveInput = async (raw: string): Promise<string> => {
37 if (raw.startsWith("at://")) {
38 const parsed = parseAtUri(raw);
39 if (!parsed) {
40 console.error("Error: invalid AT URI format.");
41 process.exit(1);
42 }
43 const did = await resolveDid(parsed.did);
44 return buildAtUri(did, parsed.collection, parsed.rkey);
45 }
46
47 const parsed = parseBskyUrl(raw);
48 if (!parsed) {
49 console.error("Error: could not parse input as AT URI or bsky.app URL.");
50 process.exit(1);
51 }
52
53 const did = await resolveDid(parsed.id);
54 return buildAtUri(did, "app.bsky.feed.post", parsed.rkey);
55};
56
57// --- Thread rendering ---
58
59const renderThread = (node: any, depth = 0) => {
60 const indent = " ".repeat(depth);
61 const prefix = depth === 0 ? ">>>" : "|";
62
63 if (node.$type === "app.bsky.feed.defs#notFoundPost") {
64 console.log(`${indent}${prefix} [deleted post]`);
65 return;
66 }
67
68 if (node.$type === "app.bsky.feed.defs#blockedPost") {
69 console.log(`${indent}${prefix} [blocked post]`);
70 return;
71 }
72
73 const post = node.post;
74 if (!post) return;
75
76 const info = extractPostViewInfo(post);
77
78 const lines = [
79 `${indent}${prefix} ${info.displayName} (@${info.handle})`,
80 `${indent} ${info.text}`,
81 ];
82
83 const likes = post.likeCount ?? 0;
84 const reposts = post.repostCount ?? 0;
85 const replies = post.replyCount ?? 0;
86 if (likes || reposts || replies) {
87 lines.push(`${indent} ${formatEngagement({ likeCount: likes, repostCount: reposts, replyCount: replies })}`);
88 }
89
90 lines.push(`${indent} ${info.bskyUrl}`);
91 console.log(lines.join("\n"));
92 console.log();
93
94 // Render replies
95 if (node.replies?.length) {
96 for (const reply of node.replies) {
97 renderThread(reply, depth + 1);
98 }
99 }
100};
101
102// --- Main ---
103
104const main = async () => {
105 const uri = await resolveInput(input);
106 console.log(`\nFetching thread: ${uri}\n`);
107
108 try {
109 const thread = await fetchGetPostThread(uri, { depth: 10, parentHeight: 5 });
110
111 // Render parent chain first
112 const parents: any[] = [];
113 let current = thread.thread?.parent;
114 while (current?.post) {
115 parents.unshift(current);
116 current = current.parent;
117 }
118
119 if (parents.length > 0) {
120 console.log("--- Parent chain ---\n");
121 for (const p of parents) {
122 renderThread(p, 0);
123 }
124 console.log("--- Original post ---\n");
125 }
126
127 // Render the thread from the target post
128 renderThread(thread.thread, 0);
129
130 if (!thread.thread?.replies?.length && parents.length === 0) {
131 console.log("(no replies yet)\n");
132 }
133 } catch (err: any) {
134 console.error(`Error: ${err.message}`);
135 process.exit(1);
136 }
137};
138
139main();