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