ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

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

refactor(api): move follow operators to hono

byarielm.fyi 4446d1d7 3310911c

verified
+251
+249
packages/api/src/routes/follow.ts
··· 1 + /** 2 + * Follow Routes 3 + * Handles batch follow operations and follow status checking 4 + */ 5 + 6 + import { Hono } from 'hono'; 7 + import { z } from 'zod'; 8 + import { authMiddleware } from '../middleware/auth'; 9 + import { followRateLimit } from '../middleware/rateLimit'; 10 + import { SessionService } from '../services/SessionService'; 11 + import { FollowService } from '../services/FollowService'; 12 + import { MatchRepository } from '../repositories/MatchRepository'; 13 + import type { AppEnv } from '../types/hono'; 14 + 15 + const follow = new Hono<AppEnv>(); 16 + 17 + // Validation schemas 18 + const batchFollowSchema = z.object({ 19 + dids: z.array(z.string().startsWith('did:')).min(1).max(100), 20 + followLexicon: z.string().optional().default('app.bsky.graph.follow'), 21 + }); 22 + 23 + const checkStatusSchema = z.object({ 24 + dids: z.array(z.string().startsWith('did:')).min(1).max(100), 25 + followLexicon: z.string().optional().default('app.bsky.graph.follow'), 26 + }); 27 + 28 + interface FollowResult { 29 + did: string; 30 + success: boolean; 31 + alreadyFollowing: boolean; 32 + error: string | null; 33 + } 34 + 35 + /** 36 + * POST /api/follow/batch-follow-users 37 + * Follow multiple users in batch 38 + * 39 + * Rate limit: 100 follows per hour (from migration plan) 40 + * Each request can follow up to 100 users with 5 concurrent operations 41 + */ 42 + follow.post( 43 + '/batch-follow-users', 44 + followRateLimit, 45 + authMiddleware, 46 + async (c) => { 47 + try { 48 + const body = await c.req.json(); 49 + const { dids, followLexicon } = batchFollowSchema.parse(body); 50 + 51 + const sessionId = c.get('sessionId'); 52 + const userDid = c.get('did'); 53 + 54 + console.log( 55 + `[Follow] Batch follow for ${dids.length} users by ${userDid}`, 56 + ); 57 + 58 + // Get authenticated agent 59 + const { agent } = await SessionService.getAgentForSession(sessionId, c); 60 + 61 + // Check which users are already followed 62 + const alreadyFollowing = await FollowService.getAlreadyFollowing( 63 + agent, 64 + userDid, 65 + dids, 66 + followLexicon, 67 + ); 68 + 69 + const matchRepo = new MatchRepository(); 70 + const CONCURRENCY = 5; // Process 5 follows in parallel 71 + 72 + // Helper function to follow a single user 73 + const followUser = async (did: string): Promise<FollowResult> => { 74 + // If already following, just update DB and return success 75 + if (alreadyFollowing.has(did)) { 76 + try { 77 + await matchRepo.updateFollowStatus(did, followLexicon, true); 78 + } catch (dbError) { 79 + console.error('Failed to update follow status in DB:', dbError); 80 + } 81 + 82 + return { 83 + did, 84 + success: true, 85 + alreadyFollowing: true, 86 + error: null, 87 + }; 88 + } 89 + 90 + // Not following yet - create the follow record 91 + try { 92 + await agent.api.com.atproto.repo.createRecord({ 93 + repo: userDid, 94 + collection: followLexicon, 95 + record: { 96 + $type: followLexicon, 97 + subject: did, 98 + createdAt: new Date().toISOString(), 99 + }, 100 + }); 101 + 102 + // Update follow status in database 103 + try { 104 + await matchRepo.updateFollowStatus(did, followLexicon, true); 105 + } catch (dbError) { 106 + console.error('Failed to update follow status in DB:', dbError); 107 + } 108 + 109 + return { 110 + did, 111 + success: true, 112 + alreadyFollowing: false, 113 + error: null, 114 + }; 115 + } catch (error) { 116 + // Rate limit handling with backoff 117 + if ( 118 + error instanceof Error && 119 + (error.message.includes('rate limit') || 120 + error.message.includes('429')) 121 + ) { 122 + const backoffDelay = 1000; // 1 second backoff for rate limits 123 + console.log( 124 + `[Follow] Rate limit hit for ${did}. Backing off for ${backoffDelay}ms...`, 125 + ); 126 + await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 127 + } 128 + 129 + return { 130 + did, 131 + success: false, 132 + alreadyFollowing: false, 133 + error: error instanceof Error ? error.message : 'Follow failed', 134 + }; 135 + } 136 + }; 137 + 138 + // Process follows in chunks with controlled concurrency 139 + const results: FollowResult[] = []; 140 + for (let i = 0; i < dids.length; i += CONCURRENCY) { 141 + const chunk = dids.slice(i, i + CONCURRENCY); 142 + const chunkResults = await Promise.allSettled( 143 + chunk.map((did) => followUser(did)), 144 + ); 145 + 146 + // Extract results from Promise.allSettled 147 + for (const result of chunkResults) { 148 + if (result.status === 'fulfilled') { 149 + results.push(result.value); 150 + } else { 151 + // This shouldn't happen as we handle errors in followUser 152 + console.error('[Follow] Unexpected promise rejection:', result.reason); 153 + results.push({ 154 + did: 'unknown', 155 + success: false, 156 + alreadyFollowing: false, 157 + error: 'Unexpected error', 158 + }); 159 + } 160 + } 161 + } 162 + 163 + const successCount = results.filter((r) => r.success).length; 164 + const failCount = results.filter((r) => !r.success).length; 165 + const alreadyFollowingCount = results.filter( 166 + (r) => r.alreadyFollowing, 167 + ).length; 168 + 169 + console.log( 170 + `[Follow] Completed: ${successCount} succeeded, ${failCount} failed, ${alreadyFollowingCount} already following`, 171 + ); 172 + 173 + return c.json({ 174 + success: true, 175 + data: { 176 + total: dids.length, 177 + succeeded: successCount, 178 + failed: failCount, 179 + alreadyFollowing: alreadyFollowingCount, 180 + results, 181 + }, 182 + }); 183 + } catch (error) { 184 + if (error instanceof z.ZodError) { 185 + return c.json( 186 + { 187 + success: false, 188 + error: 189 + 'Invalid request. Please provide a valid array of DIDs (max 100).', 190 + }, 191 + 400, 192 + ); 193 + } 194 + throw error; // Let error middleware handle it 195 + } 196 + }, 197 + ); 198 + 199 + /** 200 + * POST /api/follow/check-status 201 + * Check follow status for multiple DIDs 202 + */ 203 + follow.post('/check-status', authMiddleware, async (c) => { 204 + try { 205 + const body = await c.req.json(); 206 + const { dids, followLexicon } = checkStatusSchema.parse(body); 207 + 208 + const sessionId = c.get('sessionId'); 209 + const userDid = c.get('did'); 210 + 211 + console.log( 212 + `[Follow] Checking follow status for ${dids.length} users by ${userDid}`, 213 + ); 214 + 215 + // Get authenticated agent 216 + const { agent } = await SessionService.getAgentForSession(sessionId, c); 217 + 218 + // Check follow status 219 + const followStatus = await FollowService.checkFollowStatus( 220 + agent, 221 + userDid, 222 + dids, 223 + followLexicon, 224 + ); 225 + 226 + console.log( 227 + `[Follow] Status check complete: ${Object.values(followStatus).filter(Boolean).length} following`, 228 + ); 229 + 230 + return c.json({ 231 + success: true, 232 + data: { followStatus }, 233 + }); 234 + } catch (error) { 235 + if (error instanceof z.ZodError) { 236 + return c.json( 237 + { 238 + success: false, 239 + error: 240 + 'Invalid request. Please provide a valid array of DIDs (max 100).', 241 + }, 242 + 400, 243 + ); 244 + } 245 + throw error; // Let error middleware handle it 246 + } 247 + }); 248 + 249 + export default follow;
+2
packages/api/src/server.ts
··· 8 8 import authRoutes from "./routes/auth"; 9 9 import searchRoutes from "./routes/search"; 10 10 import resultsRoutes from "./routes/results"; 11 + import followRoutes from "./routes/follow"; 11 12 import { db } from "./db/client"; 12 13 import { sql } from "kysely"; 13 14 ··· 53 54 app.route("/api/auth", authRoutes); 54 55 app.route("/api/search", searchRoutes); 55 56 app.route("/api/results", resultsRoutes); 57 + app.route("/api/follow", followRoutes); 56 58 57 59 // Health check endpoint (Phase 3C - with database check) 58 60 app.get("/api/health", async (c) => {