This repository has no description
0

Configure Feed

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

semble / src / modules / atproto / infrastructure / services / AtProtoJetstreamService.ts
8.5 kB 301 lines
1import WebSocket from 'ws'; 2import { IFirehoseService } from '../../application/services/IFirehoseService'; 3import { FirehoseEventHandler } from '../../application/handlers/FirehoseEventHandler'; 4import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 5import { FirehoseEvent } from '../../domain/FirehoseEvent'; 6 7const DEBUG_LOGGING = true; // Set to false to disable debug logs 8 9interface JetstreamCommitEvent { 10 did: string; 11 time_us: number; 12 kind: 'commit'; 13 commit: { 14 rev: string; 15 operation: 'create' | 'update' | 'delete'; 16 collection: string; 17 rkey: string; 18 record?: any; 19 cid?: string; 20 }; 21} 22 23interface JetstreamIdentityEvent { 24 did: string; 25 time_us: number; 26 kind: 'identity'; 27 identity: { 28 did: string; 29 handle: string; 30 seq: number; 31 time: string; 32 }; 33} 34 35interface JetstreamAccountEvent { 36 did: string; 37 time_us: number; 38 kind: 'account'; 39 account: { 40 active: boolean; 41 did: string; 42 seq: number; 43 time: string; 44 }; 45} 46 47type JetstreamEvent = 48 | JetstreamCommitEvent 49 | JetstreamIdentityEvent 50 | JetstreamAccountEvent; 51 52export class AtProtoJetstreamService implements IFirehoseService { 53 private ws?: WebSocket; 54 private isRunningFlag = false; 55 private cleaningUp = false; 56 private eventCount = 0; 57 private logInterval?: NodeJS.Timeout; 58 private mostRecentEventTime?: Date; 59 private reconnectTimeout?: NodeJS.Timeout; 60 private reconnectAttempts = 0; 61 private maxReconnectAttempts = 10; 62 private reconnectDelay = 5000; // 5 seconds 63 64 constructor( 65 private firehoseEventHandler: FirehoseEventHandler, 66 private configService: EnvironmentConfigService, 67 ) {} 68 69 async start(): Promise<void> { 70 if (this.isRunningFlag) { 71 return; 72 } 73 74 try { 75 console.log( 76 `[JETSTREAM] Starting Jetstream service for collections: ${this.getFilteredCollections().join(', ')}`, 77 ); 78 79 await this.connect(); 80 this.isRunningFlag = true; 81 this.startEventCountLogging(); 82 console.log('[JETSTREAM] Jetstream service started'); 83 } catch (error) { 84 console.error('[JETSTREAM] Failed to start Jetstream service:', error); 85 throw error; 86 } 87 } 88 89 async stop(): Promise<void> { 90 if (!this.isRunningFlag) { 91 return; 92 } 93 94 console.log('[JETSTREAM] Stopping Jetstream service...'); 95 96 if (this.reconnectTimeout) { 97 clearTimeout(this.reconnectTimeout); 98 this.reconnectTimeout = undefined; 99 } 100 101 if (this.ws) { 102 this.ws.close(); 103 this.ws = undefined; 104 } 105 106 this.stopEventCountLogging(); 107 this.isRunningFlag = false; 108 console.log('[JETSTREAM] Jetstream service stopped'); 109 } 110 111 isRunning(): boolean { 112 return ( 113 this.isRunningFlag && !!this.ws && this.ws.readyState === WebSocket.OPEN 114 ); 115 } 116 117 private async connect(): Promise<void> { 118 return new Promise((resolve, reject) => { 119 const jetstreamUrl = this.buildJetstreamUrl(); 120 console.log(`[JETSTREAM] Connecting to ${jetstreamUrl}`); 121 122 this.ws = new WebSocket(jetstreamUrl); 123 124 this.ws.on('open', () => { 125 console.log('[JETSTREAM] WebSocket connection established'); 126 this.reconnectAttempts = 0; 127 resolve(); 128 }); 129 130 this.ws.on('message', (data: WebSocket.Data) => { 131 this.handleMessage(data); 132 }); 133 134 this.ws.on('close', (code: number, reason: Buffer) => { 135 console.log( 136 `[JETSTREAM] WebSocket closed: ${code} ${reason.toString()}`, 137 ); 138 if (this.isRunningFlag && !this.cleaningUp) { 139 this.scheduleReconnect(); 140 } 141 }); 142 143 this.ws.on('error', (error: Error) => { 144 console.error('[JETSTREAM] WebSocket error:', error); 145 if (this.reconnectAttempts === 0) { 146 reject(error); 147 } 148 }); 149 }); 150 } 151 152 private scheduleReconnect(): void { 153 if (this.reconnectAttempts >= this.maxReconnectAttempts) { 154 console.error('[JETSTREAM] Max reconnection attempts reached, giving up'); 155 return; 156 } 157 158 this.reconnectAttempts++; 159 const delay = 160 this.reconnectDelay * 161 Math.pow(2, Math.min(this.reconnectAttempts - 1, 5)); // Exponential backoff, max 5 162 163 console.log( 164 `[JETSTREAM] Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`, 165 ); 166 167 this.reconnectTimeout = setTimeout(async () => { 168 try { 169 await this.connect(); 170 } catch (error) { 171 console.error('[JETSTREAM] Reconnection failed:', error); 172 this.scheduleReconnect(); 173 } 174 }, delay); 175 } 176 177 private buildJetstreamUrl(): string { 178 const baseUrl = this.configService.getAtProtoConfig().jetstreamWebsocket; 179 const collections = this.getFilteredCollections(); 180 181 const params = new URLSearchParams(); 182 collections.forEach((collection) => { 183 params.append('wantedCollections', collection); 184 }); 185 186 return `${baseUrl}/subscribe?${params.toString()}`; 187 } 188 189 private handleMessage(data: WebSocket.Data): void { 190 try { 191 const message = JSON.parse(data.toString()) as JetstreamEvent; 192 this.eventCount++; 193 this.mostRecentEventTime = new Date(message.time_us / 1000); // Convert microseconds to milliseconds 194 195 if (message.kind === 'commit') { 196 this.handleCommitEvent(message); 197 } 198 // Ignore identity and account events 199 } catch (error) { 200 console.error('[JETSTREAM] Failed to parse message:', error); 201 } 202 } 203 204 private async handleCommitEvent(event: JetstreamCommitEvent): Promise<void> { 205 try { 206 // Convert Jetstream event to our FirehoseEvent format 207 const atUri = `at://${event.did}/${event.commit.collection}/${event.commit.rkey}`; 208 209 if (DEBUG_LOGGING) { 210 console.log( 211 `[JETSTREAM] Processing commit event: ${event.commit.operation} for ${event.did}`, 212 ); 213 } 214 215 // Create a synthetic Event object that matches the ATProto firehose format 216 const syntheticEvent = { 217 event: event.commit.operation, 218 did: event.did, 219 uri: atUri, 220 cid: event.commit.cid || null, 221 record: event.commit.record, 222 seq: Math.floor(event.time_us / 1000), // Use timestamp as seq 223 time: new Date(event.time_us / 1000).toISOString(), 224 rev: event.commit.rev, 225 collection: event.commit.collection, 226 }; 227 228 // Create FirehoseEvent from the synthetic event 229 const firehoseEventResult = FirehoseEvent.fromEvent( 230 syntheticEvent as any, 231 ); 232 if (firehoseEventResult.isErr()) { 233 if (!firehoseEventResult.error.message.includes('is not processable')) { 234 console.error( 235 '[JETSTREAM] Failed to create FirehoseEvent:', 236 firehoseEventResult.error, 237 ); 238 } 239 return; 240 } 241 242 const result = await this.firehoseEventHandler.handle( 243 firehoseEventResult.value, 244 ); 245 246 if (result.isErr()) { 247 console.error( 248 '[JETSTREAM] Failed to process Jetstream event:', 249 result.error, 250 ); 251 } else if (DEBUG_LOGGING) { 252 console.log(`[JETSTREAM] Successfully processed event`); 253 } 254 } catch (error) { 255 console.error('[JETSTREAM] Unhandled error in handleCommitEvent:', error); 256 } 257 } 258 259 private getFilteredCollections(): string[] { 260 const collections = this.configService.getAtProtoCollections(); 261 262 return [ 263 collections.card, 264 collections.collection, 265 collections.collectionLink, 266 collections.marginBookmark, 267 collections.marginCollection, 268 collections.marginCollectionItem, 269 collections.collectionLinkRemoval, 270 ]; 271 } 272 273 private startEventCountLogging(): void { 274 this.logInterval = setInterval( 275 () => { 276 const now = new Date(); 277 let timingInfo = ''; 278 279 if (this.mostRecentEventTime) { 280 const gapSeconds = Math.floor( 281 (now.getTime() - this.mostRecentEventTime.getTime()) / 1000, 282 ); 283 timingInfo = ` | Most recent event: ${this.mostRecentEventTime.toISOString()} | Gap: ${gapSeconds}s`; 284 } 285 286 console.log( 287 `[JETSTREAM] Events processed in last 10 minutes: ${this.eventCount}${timingInfo}`, 288 ); 289 this.eventCount = 0; // Reset counter 290 }, 291 10 * 60 * 1000, 292 ); // 10 minute intervals 293 } 294 295 private stopEventCountLogging(): void { 296 if (this.logInterval) { 297 clearInterval(this.logInterval); 298 this.logInterval = undefined; 299 } 300 } 301}