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", }, }, }, });