All files / api/src/utils sse-event-manager.ts

77.77% Statements 84/108
90.9% Branches 20/22
83.33% Functions 10/12
77.77% Lines 84/108

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196                                                    1x 17x 17x   17x         17x     18x 1x 1x 1x   18x 18x 18x 18x 18x 18x 18x         17x 4x 4x   3x 3x 4x         17x 16x 16x     16x 16x 5x 5x 5x 1x 1x 1x 16x 16x     16x 1x 1x 16x         17x 2x 2x     2x 2x 2x 2x 2x 2x 2x       2x 2x     2x     2x         17x 18x 18x 18x         17x   1x         1x   1x 1x         17x                   17x 10x 10x         17x 2x 2x 2x 2x 17x     1x         1x 13x 13x 13x 13x 13x 13x 13x 13x 13x         1x                      
import type { SSEEventData, SSEEventType } from '@shared/types/sse';
 
/**
 * SSE Stream Writer
 * Interface for writing to SSE stream (compatible with Hono StreamingApi)
 */
interface SSEStreamWriter {
  write: (data: string) => Promise<unknown>;
}
 
/**
 * SSE Client Connection
 * Represents a single client connection for Server-Sent Events
 */
interface SSEClient {
  id: string;
  stream: SSEStreamWriter;
  userId: string;
  connectedAt: number;
}
 
/**
 * SSE Event Manager
 * Manages all active SSE connections and broadcasts events to clients
 * Implements singleton pattern for global event broadcasting
 */
class SSEEventManager {
  private clients: Map<string, SSEClient> = new Map();
  private pingInterval: NodeJS.Timeout | null = null;
  // Track whether ping interval has been started (lazy initialization for Cloudflare Workers)
  private pingIntervalStarted = false;
 
  /**
   * Register a new SSE client connection
   */
  addClient(id: string, stream: SSEStreamWriter, userId: string): void {
    // Start ping interval lazily on first client connection
    // This avoids setInterval in global scope (not allowed in Cloudflare Workers)
    if (!this.pingIntervalStarted) {
      this.startPingInterval();
      this.pingIntervalStarted = true;
    }
 
    this.clients.set(id, {
      id,
      stream,
      userId,
      connectedAt: Date.now(),
    });
  }
 
  /**
   * Remove a client connection
   */
  removeClient(id: string): void {
    const client = this.clients.get(id);
    if (client) {
      // Stream cleanup is handled by Hono
      this.clients.delete(id);
    }
  }
 
  /**
   * Broadcast an event to all connected clients
   */
  async broadcast(event: SSEEventData): Promise<void> {
    const message = this.formatSSEMessage(event);
    const failedClients: string[] = [];
 
    // Send to all clients in parallel
    await Promise.all(
      Array.from(this.clients.entries()).map(async ([clientId, client]) => {
        try {
          await client.stream.write(message);
        } catch (error) {
          console.error(`[SSE] Failed to send to client ${clientId}:`, error);
          failedClients.push(clientId);
        }
      }),
    );
 
    // Clean up failed clients
    for (const clientId of failedClients) {
      this.removeClient(clientId);
    }
  }
 
  /**
   * Broadcast an event to a specific user's connections
   */
  async broadcastToUser(userId: string, event: SSEEventData): Promise<void> {
    const message = this.formatSSEMessage(event);
    const failedClients: string[] = [];
 
    // Send to user's clients in parallel
    await Promise.all(
      Array.from(this.clients.entries())
        .filter(([, client]) => client.userId === userId)
        .map(async ([clientId, client]) => {
          try {
            await client.stream.write(message);
          } catch (error) {
            console.error(`[SSE] Failed to send to client ${clientId}:`, error);
            failedClients.push(clientId);
          }
        }),
    );
 
    // Clean up failed clients
    for (const clientId of failedClients) {
      this.removeClient(clientId);
    }
  }
 
  /**
   * Format SSE message according to the SSE protocol
   */
  private formatSSEMessage(event: SSEEventData): string {
    const data = JSON.stringify(event);
    return `event: message\ndata: ${data}\n\n`;
  }
 
  /**
   * Send periodic ping to keep connections alive
   */
  private startPingInterval(): void {
    // Send ping every 30 seconds
    this.pingInterval = setInterval(() => {
      void this.broadcast({
        type: 'ping',
        timestamp: Date.now(),
      });
    }, 30 * 1000);
    // Allow process to exit even if interval is active (e.g. in tests)
    this.pingInterval.unref();
  }
 
  /**
   * Stop ping interval (for cleanup)
   */
  stopPingInterval(): void {
    if (this.pingInterval) {
      clearInterval(this.pingInterval);
      this.pingInterval = null;
    }
  }
 
  /**
   * Get number of active connections
   */
  getClientCount(): number {
    return this.clients.size;
  }
 
  /**
   * Get client IDs for a specific user
   */
  getUserClients(userId: string): string[] {
    return Array.from(this.clients.values())
      .filter((client) => client.userId === userId)
      .map((client) => client.id);
  }
}
 
// Export singleton instance
export const sseEventManager = new SSEEventManager();
 
/**
 * Helper function to emit an event to all clients
 */
export function emitSSEEvent(
  type: SSEEventType,
  data?: Record<string, unknown>,
): void {
  void sseEventManager.broadcast({
    type,
    timestamp: Date.now(),
    data,
  });
}
 
/**
 * Helper function to emit an event to a specific user
 */
export function emitSSEEventToUser(
  userId: string,
  type: SSEEventType,
  data?: Record<string, unknown>,
): void {
  void sseEventManager.broadcastToUser(userId, {
    type,
    timestamp: Date.now(),
    data,
  });
}