Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 127 lines 4.1 kB view raw
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}