Ramble-FE / machine / call-machine.ts
call-machine.ts
Raw
import { ServiceDependencies } from "@/services";
import { and, assign, setup } from "xstate";
import * as actions from "./actions";
import * as actors from "./actors";
import * as guards from "./guards";
import { CallContext, CallEvent } from "./types";

export const callMachine = setup({
    types: {
        context: {} as CallContext,
        events: {} as CallEvent,
        input: {} as ServiceDependencies,
    },
    guards: {
        isSignalingReady: guards.isSignalingReady,
        canDisconnect: guards.canDisconnect,
        hasLocalStream: guards.hasLocalStream,
        hasRemoteStream: guards.hasRemoteStream,
        canMatching: guards.canMatching,
    },
    actors: {
        // fromCallback
        listenToMedia: actors.listenToMedia,
        listenToSignaling: actors.listenToSignaling,
        listenToMatchResult: actors.listenToMatchResult,
        listenToRTCSignal: actors.listenToRTCSignal,
        listenToRTCConnection: actors.listenToRTCConnection,
        // fromPromise
        createOffer: actors.createOffer,
        restartIce: actors.restartIce,

    },
    actions: {
        switchCamera: actions.switchCamera,
        toggleAudio: actions.toggleAudio,
        connectSignaling: actions.connectSignaling,
        requestMatch: actions.requestMatch,
        disconnectSignaling: actions.disconnectSignaling,
        cancelMatch: actions.cancelMatch,
        initializeConnection: actions.initializeConnection,
        closeConnection: actions.closeConnection,
        disposeConnection: actions.disposeConnection,
    },
}).createMachine({
    id: "webrtc",
    initial: "idle",
    context: ({ input }) => ({
        services: input,

        // Media
        localStream: null,
        remoteStream: null,

        // Session
        matchRequest: null,
        sessionInfo: null,

        // Error
        error: null,
    }),
    on: {
        "user.switch-camera": {
            guard: "hasLocalStream",
            actions: "switchCamera",
        },
        "user.toggle-audio": {
            guard: "hasLocalStream",
            actions: "toggleAudio",
        },
    },

    states: {
        // ===== Idle State =====
        idle: {
            on: { "lifecycle.init": "initializing" },
        },
        // ===== Initializing State (미디어 스트림 초기화) =====
        initializing: {
            invoke: {
                id: "mediaCallback",
                src: "listenToMedia",
                input: ({ context }) => context.services,
            },
            on: {
                "media.ready": {
                    target: "ready",
                    actions: assign({
                        localStream: ({ event }) => event.stream
                    }),
                },
                "media.error": {
                    target: "idle",
                    actions: assign({
                        error: ({ event }) => event.error 
                    }),
                },
            },
        },
        // ===== Ready State (미디어 준비 완료, 세션 시작 대기) =====
        ready: {
            on: {
                "user.start-call": {
                    target: "session",
                    guard: "hasLocalStream",
                    actions: assign({
                        matchRequest: ({ event }) => event.request 
                    }),
                },
                "lifecycle.reset": {
                    target: "idle",
                    actions: assign({
                        localStream: () => null,
                        remoteStream: () => null,
                        sessionInfo: () => null,
                        error: () => null,
                    }),
                },
            },
        },
        // ===== Session State (병렬 상태) =====
        session: {
            type: "parallel",
            on: {
                "user.end-call": {
                    target: "disconnecting",
                    guard: "canDisconnect",
                },
            },

            states: {
                // ----- Signaling State (웹소켓 연결 상태) -----
                signaling: {
                    initial: "connecting",
                    invoke: {
                        id: "signalingCallback",
                        src: "listenToSignaling",
                        input: ({ context }) => context.services,
                    },

                    states: {
                        connecting: {
                            entry: "connectSignaling",
                            on: {
                                "signaling.connected": "connected",
                                "signaling.disconnected": "reconnecting",
                                "signaling.error": {
                                    target: "error",
                                    actions: assign({
                                        error: ({ event }) => event.error 
                                    }),
                                },
                            },
                        },
                        connected: {
                            on: {
                                "signaling.disconnected": "reconnecting",
                                "signaling.error": {
                                    target: "error",
                                    actions: assign({
                                        error: ({ event }) => event.error 
                                    }),
                                },
                            },
                        },
                        // reconnectDelay에 따라 자동 재연결 시도 중인 상태
                        reconnecting: {
                            on: {
                                "signaling.connected": "connected",
                                "signaling.error": {
                                    target: "error",
                                    actions: assign({
                                        error: ({ event }) => event.error 
                                    }),
                                },
                            },
                        },
                        error: {
                            entry:  ({ context }) => {
                                console.error("[CallMachine] Signaling error:", context.error);
                            },
                            after: {
                                3000: [
                                    {
                                        guard: ({ context }) => { // TODO: 에러는 추후 이슈를 통해서 관리
                                            const msg = context.error?.message || '';
                                            return !msg.includes('401') && !msg.includes('403');
                                        },
                                        target: "connecting",
                                        actions: assign({ error: () => null })
                                    },
                                    {
                                        target: "#webrtc.disconnecting"
                                    }
                                ]
                            }
                        },
                    },
                },
                // ----- Flow State (Matching → Connecting → Connected) -----
                flow: {
                    initial: "idle",
                    invoke:  {
                        id: "webrtcCallback",
                        src: "listenToRTCConnection",
                        input: ({ context }) => context.services,
                    },

                    states: {
                        idle: {
                            always: {
                                target: "matching",
                                guard: and(['isSignalingReady', 'canMatching'])                                
                            },
                        },
                        // 매칭 중
                        matching: {
                            invoke: {
                                id: "matchingCallback",
                                src: "listenToMatchResult",
                                input: ({ context }) => ({
                                    services: context.services,
                                    matchRequest: context.matchRequest!,
                                }),
                            },
                            on: {
                                "match.found": {
                                    target: "connecting",
                                    guard: "hasLocalStream",
                                    actions: assign({
                                        sessionInfo: ({ event }) => event.result 
                                    }),
                                },
                                "match.failed": {
                                    target: "#webrtc.disconnecting",
                                },
                                "match.timeout": {
                                    target: "#webrtc.disconnecting",
                                    actions: assign({
                                        error: ({ event }) => event.error 
                                    }),
                                },
                                "match.error": {
                                    target: "#webrtc.disconnecting",
                                    actions: assign({
                                        error: ({ event }) => event.error 
                                    }),
                                },
                            },
                        },
                        // WebRTC 연결 중
                        connecting: {
                            entry: "initializeConnection",
                            invoke: [
                                {
                                    src: "listenToRTCSignal",
                                    input: ({ context }) => ({
                                        services: context.services,
                                        sessionInfo: context.sessionInfo!,
                                    }),
                                },
                                {
                                    id: "createOffer",
                                    src: "createOffer",
                                    input: ({ context }) => ({
                                        services: context.services,
                                        sessionInfo: context.sessionInfo!,
                                    }),
                                    onError: {
                                        target: '#webrtc.disconnecting', // TODO: retry는 재시도 고도화 작업에서 진행
                                        actions: assign({
                                            error: ({ event }) => event.error as Error,
                                        }),
                                    }
                                }
                            ],
                            on: {
                                "remote.stream-ready": {
                                    actions: assign({
                                        remoteStream: ({ event }) => event.stream,
                                    }),
                                },
                                "connection.ice-connected": {
                                    target: "connected",
                                },
                                "connection.failed": {
                                    target: "reconnecting",
                                    actions: assign({
                                        error: ({ event }) => event.error,
                                    })
                                },
                            },
                        },
                        // 연결됨
                        connected: {
                            on: {
                                "user.next-call": {
                                    target: "idle",
                                    actions: [
                                        "closeConnection",
                                        "cancelMatch",
                                        assign({
                                            matchRequest: ({ event }) => event.request,
                                            remoteStream: () => null,
                                            sessionInfo: () => null,
                                        })
                                    ],
                                },
                                "connection.ice-disconnected": {
                                    target: "disconnected",
                                },
                                "connection.failed": { // 바로 failed되는 경우가 있을 수 있음
                                    target: "reconnecting",
                                    actions: assign({
                                        error: ({ event }) => event.error,
                                    })
                                },
                            },
                        },
                         // connected로 자동 복구될 수 있기에 일단 대기. 복구 안 될 시 reconnecting
                        disconnected: {
                            on: {
                                "connection.ice-connected": {
                                    target: "connected",
                                },
                                "connection.failed": {
                                    target: "reconnecting",
                                    actions: assign({
                                        error: ({ event }) => event.error,
                                    })
                                },
                            },
                        },
                        // 재연결 중
                        reconnecting: {
                            invoke: [
                                {
                                    src: "listenToRTCSignal",
                                    input: ({ context }) => ({
                                        services: context.services,
                                        sessionInfo: context.sessionInfo!,
                                    }),
                                },
                                {
                                    id: "restartIce",
                                    src: "restartIce",
                                    input: ({ context }) => ({
                                        services: context.services,
                                        sessionInfo: context.sessionInfo!,
                                    }),
                                }
                            ],
                            on: {
                                "remote.stream-ready": {
                                    actions: assign({
                                        remoteStream: ({ event }) => event.stream,
                                    }),
                                },
                                "connection.ice-connected": {
                                    target: "connected",
                                },
                            },
                            after: {
                                10000: {
                                    target: "#webrtc.disconnecting",
                                    actions: assign({
                                        error: () => new Error("ICE reconnection timeout")
                                    })
                                }
                            }
                        },
                    },
                }
            }
        },
        // ===== Disconnecting State =====
        disconnecting: {
            entry: [
                "disposeConnection", 
                "cancelMatch", 
                "disconnectSignaling",
                assign({
                    remoteStream: () => null,
                    sessionInfo: () => null,
                    error: () => null,
                }),
            ],
            always: {
                target: "ready",
            },
        },
    },
});