Ramble-FE / machine / actors.ts
actors.ts
Raw
import { ServiceDependencies } from "@/services";
import { MatchInfo } from "@/services/matching";
import { Destination, SignalMessage, SignalType, Topic } from "@/services/signaling";
import { SignalingRole } from "@/services/webrtc";
import { fromCallback, fromPromise } from "xstate";
import { CallContext } from "./types";

const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

// ===== listeners =====

/**
 * 미디어 스트림 초기화 및 이벤트 리스닝
 */
export const listenToMedia = fromCallback(({ sendBack, input }) => {
    const { mediaManager } = input as ServiceDependencies;

    const handleStreamReady = (stream: MediaStream) => {
        sendBack({ type: "media.ready", stream});
    };

    const handleStreamError = (error: Error) => {
        sendBack({ type: "media.error", error})
    };

    // 리스너 등록
    mediaManager.on("stream:ready", handleStreamReady);
    mediaManager.on("stream:error", handleStreamError);

    // 미디어 초기화
    mediaManager.initialize();

    // cleanup
    return () => {
        mediaManager.off("stream:ready", handleStreamReady);
        mediaManager.off("stream:error", handleStreamError);
    };
});

/**
 * WebSocket 연결 관리
 */
export const listenToSignaling = fromCallback(({ sendBack, input }) => {
    const { signalingClient } = input as ServiceDependencies;

    const handleConnected = () => {
        sendBack({ type: "signaling.connected" });
    };

    const handleDisconnected = () => {
        sendBack({ type: 'signaling.disconnected' });
    };

    const handleError = (error: Error) => {
        sendBack({ type: 'signaling.error', error });
    };

    // 리스너 등록
    signalingClient.on('signaling:connected', handleConnected);
    signalingClient.on('signaling:disconnected', handleDisconnected);
    signalingClient.on('signaling:error', handleError);

    // cleanup
    return () => {
        signalingClient.off('signaling:connected', handleConnected);
        signalingClient.off('signaling:disconnected', handleDisconnected);
        signalingClient.off('signaling:error', handleError);
    };
});

/**
 * 매칭 요청 및 이벤트 리스닝
 */
export const listenToMatchResult = fromCallback(({ sendBack, input }) => {
    const { services, matchRequest } = input as CallContext;

    const handleMatchFound = (result: MatchInfo) => {
        sendBack({ type: "match.found", result });
    };

    const handleMatchFailed = (error: Error | string) => {
        sendBack({ type: "match.failed" });
    };

    const handleMatchTimeout = (error: Error | string) => {
        sendBack({ type: "match.timeout", error });
    };

    const handleMatchError = (error: Error) => {
        sendBack({ type: "match.error", error });
    };

    // 리스너 등록
    services.matchingService.on("matching:found", handleMatchFound);
    services.matchingService.on("matching:failed", handleMatchFailed);
    services.matchingService.on("matching:timeout", handleMatchTimeout);
    services.matchingService.on("matching:error", handleMatchError);

    // 매칭 요청
    services.matchingService.requestMatch(matchRequest!);

    // cleanup
    return () => {
        services.matchingService.off("matching:found", handleMatchFound);
        services.matchingService.off("matching:failed", handleMatchFailed);
        services.matchingService.off("matching:timeout", handleMatchTimeout);
        services.matchingService.off("matching:error", handleMatchError);
    };
});

/**
 * Offer, Answer, IceCandidate 신호 교환 
 */
export const listenToRTCSignal = fromCallback(({ sendBack, input }) => {
    const { services, sessionInfo } = input as CallContext;

    const signalingClient = services.signalingClient;
    const rtcConnectionManager = services.rtcConnectionManager;

    const handleSignalMassage = async (channel: Topic, payload: any) => {
        if (channel !== Topic.SIGNALING) return;

        const response = payload as SignalMessage;

        try {
            switch (response.type) {
                case SignalType.OFFER:
                    await rtcConnectionManager.setRemoteDescription(response.data);
                    const answer = await rtcConnectionManager.createAnswer();

                    signalingClient.send(Destination.SIGNALING, { 
                        receiverId: sessionInfo?.otherUserId, 
                        type: SignalType.ANSWER, 
                        data: answer
                    });
                    
                    break;
                case SignalType.ANSWER:
                    await rtcConnectionManager.setRemoteDescription(response.data);
                    break;
                case SignalType.CANDIDATE: 
                    await rtcConnectionManager.addIceCandidate(response.data);
                    break;
            }
        } catch (error) {
            console.error("SignalMessage 처리 중 오류:", error);

            sendBack({
                type: "connection.failed",
                error: error instanceof Error ? error : new Error(String(error))
            }); 
        }
    };

    const handleIceCandidate = (candidate: RTCIceCandidate) => {
        signalingClient.send(Destination.SIGNALING, { 
            receiverId: sessionInfo?.otherUserId, 
            type: SignalType.CANDIDATE, 
            data: candidate
        });
    };

    // 구독
    const signalSubscription = signalingClient.subscribe(Topic.SIGNALING);

    // 리스너 등록
    signalingClient.on('message', handleSignalMassage);
    rtcConnectionManager.on("rtc:ice-candidate", handleIceCandidate);

    // cleanup
    return () => {
        signalSubscription.unsubscribe();
        signalingClient.off('message', handleSignalMassage);
        rtcConnectionManager.off("rtc:ice-candidate", handleIceCandidate);
    };
});

/**
 * RTCPeerConnection 이벤트 리스닝
 */
export const listenToRTCConnection = fromCallback(({ sendBack, input }) => {
    const { rtcConnectionManager } = input as ServiceDependencies;
    
    const handleTrackAdded = (stream: MediaStream) => {
        sendBack({ type: "remote.stream-ready", stream });
    };

    const handleConnectionStateChange = (state: RTCPeerConnectionState) => {
        sendBack({ type: "connection.state-changed", state });
    };

    const handleIceConnectionStateChange = (state: RTCIceConnectionState) => {
        switch(state) {
            case "connected":
            case "completed":
                sendBack({ type: "connection.ice-connected" });
                break;
            case "disconnected":
                sendBack({ type: "connection.ice-disconnected" });
                break;
            case "failed":
                sendBack({ type: "connection.ice-failed" });
                break;
        }
    };

    const handleError = (error: Error) => {
        sendBack({ type: "connection.failed", error });
    };

    // 리스너 등록
    rtcConnectionManager.on("rtc:track-added", handleTrackAdded);
    rtcConnectionManager.on("rtc:connection-state-change", handleConnectionStateChange);
    rtcConnectionManager.on("rtc:ice-connection-state-change", handleIceConnectionStateChange);
    rtcConnectionManager.on("rtc:error", handleError);

    // cleanup
    return () => {
        rtcConnectionManager.off("rtc:track-added", handleTrackAdded);
        rtcConnectionManager.off("rtc:connection-state-change", handleConnectionStateChange);
        rtcConnectionManager.off("rtc:ice-connection-state-change", handleIceConnectionStateChange);
        rtcConnectionManager.off("rtc:error", handleError);
    };
});

// ===== actions =====

const createOfferBase = async (
    services: ServiceDependencies,
    sessionInfo: MatchInfo,
    isRestart: boolean = false
) => {
    if (sessionInfo.role === SignalingRole.ANSWER_USER) {
        return {} as RTCSessionDescriptionInit;
    }

    await delay(1000); // 임시 딜레이

    try {
        const offer = isRestart
            ? await services.rtcConnectionManager.restartIce()
            : await services.rtcConnectionManager.createOffer();

        services.signalingClient.send(Destination.SIGNALING, { 
            receiverId: sessionInfo.otherUserId, 
            type: SignalType.OFFER, 
            data: offer
        });
    } catch (error) {
        console.error("offer 생성 과정 에러: ", error);
        throw error;
    }
};

/**
 * Offer 생성
 */
export const createOffer = fromPromise(async ({ input }) => {
    const { services, sessionInfo } = input as CallContext;
    return createOfferBase(services, sessionInfo!, false);
});

/**
 * Restart Offer 생성
 */
export const restartIce = fromPromise(async ({ input }) => {
    const { services, sessionInfo } = input as CallContext;
    return createOfferBase(services, sessionInfo!, true);
});