// Pub/Sub abstraction with two implementations: // - memory: in-process EventEmitter (single instance only) // - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches // subscribers on every instance // // Messages are JSON-serialized. Selection happens once based on REDIS_URL. import { EventEmitter } from 'events'; import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js'; export type PubSubHandler = (message: any) => void; export interface PubSub { readonly backend: 'memory' | 'redis'; publish(channel: string, message: any): Promise; // Returns an unsubscribe function for this specific handler. subscribe(channel: string, handler: PubSubHandler): Promise<() => void>; } // ==================== Memory implementation ==================== class MemoryPubSub implements PubSub { readonly backend = 'memory' as const; private emitter = new EventEmitter(); constructor() { // SSE fan-out can attach many listeners to the same channel; lift the cap. this.emitter.setMaxListeners(0); } async publish(channel: string, message: any): Promise { this.emitter.emit(channel, message); } async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> { this.emitter.on(channel, handler); return () => this.emitter.off(channel, handler); } } // ==================== Redis implementation ==================== class RedisPubSub implements PubSub { readonly backend = 'redis' as const; // Per-channel handler sets so a single Redis subscription fans out locally. private handlers = new Map>(); private wired = false; private ensureWired(): void { if (this.wired) return; const sub = getSubscriber(); if (!sub) return; this.wired = true; sub.on('message', (channel: string, payload: string) => { const set = this.handlers.get(channel); if (!set || set.size === 0) return; let parsed: any = payload; try { parsed = JSON.parse(payload); } catch { // Leave as raw string if it was not JSON. } for (const handler of set) { try { handler(parsed); } catch (err: any) { console.error('[pubsub] handler error:', err?.message || err); } } }); } async publish(channel: string, message: any): Promise { const redis = getRedis(); if (!redis) return; try { await redis.publish(channel, JSON.stringify(message)); } catch (err: any) { console.error('[pubsub] publish error:', err?.message || err); } } async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> { this.ensureWired(); const sub = getSubscriber(); if (!sub) return () => undefined; let set = this.handlers.get(channel); if (!set) { set = new Set(); this.handlers.set(channel, set); try { await sub.subscribe(channel); } catch (err: any) { console.error('[pubsub] subscribe error:', err?.message || err); } } set.add(handler); return () => { const current = this.handlers.get(channel); if (!current) return; current.delete(handler); if (current.size === 0) { this.handlers.delete(channel); sub.unsubscribe(channel).catch(() => undefined); } }; } } // ==================== Selection ==================== let instance: PubSub | null = null; export function getPubSub(): PubSub { if (!instance) { instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub(); } return instance; }