Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
1import { and, count, isNotNull, ne, sql } from 'drizzle-orm';
2import type { FastifyInstance } from 'fastify';
3import type { Database } from '../db/index.js';
4import type { ValkeyClient } from '../cache/index.js';
5import { profiles } from '../db/schema/index.js';
6
7const PROFILE_COUNT_KEY = 'stats:profile-count';
8const PROFILE_COUNT_TTL = 900; // 15 minutes
9
10const ATPROTO_STATS_KEY = 'stats:atproto';
11const ATPROTO_STATS_TTL = 86400; // 24 hours
12
13const AVATARS_KEY = 'stats:avatars';
14const AVATARS_TTL = 900; // 15 minutes
15
16// Estimated non-Bluesky ATProto DIDs from PLC directory sampling (2026-03-15).
17// bsky-users.theo.io tracks Bluesky accounts only (~43M). PLC directory holds
18// all ATProto DIDs including Blacksky, Northsky, Eurosky etc. (~60M).
19const PLC_OFFSET = 17_000_000;
20
21interface AtprotoStats {
22 userCount: number;
23 growthPerSecond: number;
24 timestamp: number;
25}
26
27async function fetchAtprotoStats(): Promise<AtprotoStats | null> {
28 try {
29 const res = await fetch('https://bsky-users.theo.io/', {
30 signal: AbortSignal.timeout(5000),
31 });
32 if (!res.ok) return null;
33 const html = await res.text();
34
35 const countMatch = html.match(/"last_user_count":(\d+)/);
36 const growthMatch = html.match(/"growth_per_second":([\d.]+)/);
37 const tsMatch = html.match(/"timestamp":(\d+)/);
38
39 if (!countMatch) return null;
40
41 return {
42 userCount: Number(countMatch[1]),
43 growthPerSecond: growthMatch ? Number(growthMatch[1]) : 0,
44 timestamp: tsMatch ? Number(tsMatch[1]) : Math.floor(Date.now() / 1000),
45 };
46 } catch {
47 return null;
48 }
49}
50
51export function registerStatsRoutes(
52 app: FastifyInstance,
53 db: Database,
54 valkey: ValkeyClient | null,
55) {
56 app.get('/api/stats', async (_request, reply) => {
57 // --- Profile count (15 min cache) ---
58 let profileCount = 0;
59 if (valkey) {
60 const cached = await valkey.get(PROFILE_COUNT_KEY);
61 if (cached !== null) {
62 profileCount = Number(cached);
63 } else {
64 const [result] = await db.select({ value: count() }).from(profiles);
65 profileCount = result?.value ?? 0;
66 await valkey.setex(PROFILE_COUNT_KEY, PROFILE_COUNT_TTL, String(profileCount));
67 }
68 } else {
69 const [result] = await db.select({ value: count() }).from(profiles);
70 profileCount = result?.value ?? 0;
71 }
72
73 // --- ATProto stats (24h cache) ---
74 let atproto: AtprotoStats | null = null;
75 if (valkey) {
76 const cached = await valkey.get(ATPROTO_STATS_KEY);
77 if (cached !== null) {
78 atproto = JSON.parse(cached) as AtprotoStats;
79 } else {
80 atproto = await fetchAtprotoStats();
81 if (atproto) {
82 await valkey.setex(ATPROTO_STATS_KEY, ATPROTO_STATS_TTL, JSON.stringify(atproto));
83 }
84 }
85 } else {
86 atproto = await fetchAtprotoStats();
87 }
88
89 // --- Avatars (15 min cache) ---
90 let avatars: string[] = [];
91 if (valkey) {
92 const cached = await valkey.get(AVATARS_KEY);
93 if (cached !== null) {
94 avatars = JSON.parse(cached) as string[];
95 } else {
96 const rows = await db
97 .select({ avatarUrl: profiles.avatarUrl })
98 .from(profiles)
99 .where(and(isNotNull(profiles.avatarUrl), ne(profiles.avatarUrl, '')))
100 .orderBy(sql`random()`)
101 .limit(30);
102 avatars = rows.map((r) => r.avatarUrl).filter((url): url is string => url !== null);
103 await valkey.setex(AVATARS_KEY, AVATARS_TTL, JSON.stringify(avatars));
104 }
105 } else {
106 const rows = await db
107 .select({ avatarUrl: profiles.avatarUrl })
108 .from(profiles)
109 .where(and(isNotNull(profiles.avatarUrl), ne(profiles.avatarUrl, '')))
110 .orderBy(sql`random()`)
111 .limit(30);
112 avatars = rows.map((r) => r.avatarUrl).filter((url): url is string => url !== null);
113 }
114
115 return reply.send({
116 profileCount,
117 avatars,
118 atproto: atproto
119 ? {
120 userCount: atproto.userCount + PLC_OFFSET,
121 growthPerSecond: atproto.growthPerSecond,
122 timestamp: atproto.timestamp,
123 }
124 : null,
125 });
126 });
127}