Ramble-FE / services / signaling / signaling-client.ts
signaling-client.ts
Raw
import { SIGNALING_CONFIG } from "@/config";
import { tokenStorage } from "@/utils";
import { Client, StompSubscription } from "@stomp/stompjs";
import { EventEmitter } from "eventemitter3";
import SockJS from "sockjs-client";
import { ISignalingClient } from "./signaling.interface";
import { Destination, SignalingEventMap, Subscription, Topic } from "./signaling.types";

export class SignalingClient extends EventEmitter<SignalingEventMap> implements ISignalingClient {
    private client?: Client;
    private connectPromise?: Promise<void>;
    private subscriptions: Map<Topic, StompSubscription> = new Map();

    private async _doConnect(): Promise<void> {
        return new Promise((resolve, reject) => {
            this.client = new Client({
                webSocketFactory: () => new SockJS(SIGNALING_CONFIG.serverUrl),
                reconnectDelay: SIGNALING_CONFIG.reconnectDelay,
                heartbeatIncoming: SIGNALING_CONFIG.heartbeatIncoming,
                heartbeatOutgoing: SIGNALING_CONFIG.heartbeatOutgoing,
                connectionTimeout: SIGNALING_CONFIG.connectionTimeout,

                debug: (str: string) => {
                    if (process.env.NODE_ENV !== 'production') {
                        console.log("[STOMP]", str);
                    }
                },
                beforeConnect: async (client) => {
                    // TODO: 만료 검증 과정은 추후 추가
                    const token = await tokenStorage.getAccessToken();
                    client.connectHeaders = token ? { Authorization: `Bearer ${token}` } : {};
                },
                onConnect: (frame) => {
                    console.log("[SignalingClient] Connected")
                    this.emit("signaling:connected");
                    resolve();
                },
                onDisconnect: (frame) => {
                    console.log("[SignalingClient] Disconnected")
                    // this.emit("signaling:disconnected"); // 정상 종료이기에 emit X
                },
                onWebSocketClose: (event) => {
                    console.warn("[SignalingClient] WebSocket closed");

                    if (this.client?.active) {
                        console.log(`연결 끊김 (Clean: ${event.wasClean}, Code: ${event.code}). 재연결 상태로 전환.`);
                        this.emit("signaling:disconnected");
                    }
                },
                onStompError: (frame) => {
                    console.error("[SignalingClient] Stomp error:", frame);

                    const error = new Error(`STOMP error: ${frame.headers["message"] || "Unknown error"}`); 
                    this.emit("signaling:error", error);
                    reject(error);
                },
                onWebSocketError: (event) => {
                    console.error("[SignalingClient] WebSocket error:", event);

                    const error = new Error("WebSocket error");
                    this.emit("signaling:error", error);
                    reject(error);
                },
            });

            this.client.activate();
        });
    }

    private unsubscribeAll(): void {
        if (this.subscriptions.size === 0) {
            console.log('[SignalingClient] No active subscriptions to unsubscribe');
            return;
        }

        console.log(`[SignalingClient] Unsubscribing from ${this.subscriptions.size} subscriptions`);

        this.subscriptions.forEach((subscription) => {
            try {
                subscription.unsubscribe();
            } catch (error) {
                console.error("[SignalingClient] Error unsubscribing:", error);
            }
        });

        this.subscriptions.clear();
    }

    async connect(): Promise<void> {
        if (this.client?.connected) {
            console.log("[SignalingClient] Already connected or connecting");
            return;
        }

        if (this.connectPromise) {
            console.log("[SignalingClient] Connection already in progress");
            return this.connectPromise;
        }

        this.connectPromise = this._doConnect();

        try {
            await this.connectPromise;
        } catch (error) {
            console.error("[SignalingClient] Connection error:", error);
        } finally {
            this.connectPromise = undefined;
        }
    }

    disconnect(): void {
        if (!this.client?.connected) {
            return;
        }

        console.log("[SignalingClient] Disconnecting...");

        // Unsubscribe from all channels
        this.unsubscribeAll();

        // Deactivate client
        try {
            this.client.deactivate();
        } catch (error) {
            console.error("[SignalingClient] Error deactivating client:", error);
        }

        this.client = undefined;
    }

    isConnected(): boolean {
        return this.client?.connected || false;
    }

    subscribe(channel: Topic): Subscription {
        if (!this.client?.connected) {
            throw new Error("Cannot subscribe: client not connected");
        }

        if (this.subscriptions.has(channel)) {
            console.log(`[SignalingClient] Already subscribed to ${channel}`);

            return { 
                unsubscribe: () => {
                    const sub = this.subscriptions.get(channel);
                    if (sub) {
                        sub.unsubscribe();
                        this.subscriptions.delete(channel);
                    }
                } 
            };
        }

        const subscription = this.client.subscribe(channel, (message) => {
            try {
                const body = message.body ? JSON.parse(message.body) : null;
                
                console.log(`[SignalingClient] Message received from ${channel}:`, body);
                this.emit('message', channel, body);
            } catch (error) {
                console.error("[SignalingClient] Failed to parse message:", error);
            }
        });

        this.subscriptions.set(channel, subscription);

        return {
            unsubscribe: () => {
                subscription.unsubscribe();
                this.subscriptions.delete(channel);
                console.log(`[SignalingClient] Unsubscribed from ${channel}`);
            },
        }
    }

    send<T = unknown>(destination: Destination, payload: T): void {
        if (!this.client?.connected) {
            throw new Error("Cannot send message: client not connected");
        }

        console.log(`[SignalingClient] Sending to ${destination}:`, payload);

        try {
            this.client.publish({
                destination,
                body: JSON.stringify(payload),
                headers: { "content-type": "application/json" },
            });
        } catch (error: any) {
            console.error("[SignalingClient] Failed to send message:", error);

            throw new Error(`Failed to send message: ${error.message}`);
        }
    }

}