/* * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import ms from 'ms'; import { actions, assign, createMachine } from 'xstate'; const { raise } = actions; const ERROR_WAIT_TIMES = ['200', '1s', '5s', '30s'].map(ms); export interface WebSocketContext { errors: string[]; } export type WebSocketEvent = | { type: 'CONNECT' } | { type: 'DISCONNECT' } | { type: 'OPENED' } | { type: 'TAB_VISIBLE' } | { type: 'TAB_HIDDEN' } | { type: 'PAGE_HIDE' } | { type: 'PAGE_SHOW' } | { type: 'PAGE_FREEZE' } | { type: 'PAGE_RESUME' } | { type: 'ONLINE' } | { type: 'OFFLINE' } | { type: 'GENERATION_STARTED' } | { type: 'GENERATION_ENDED' } | { type: 'ERROR'; message: string }; export default createMachine( { id: 'webSocket', predictableActionArguments: true, schema: { context: {} as WebSocketContext, events: {} as WebSocketEvent, }, tsTypes: {} as import('./webSocketMachine.typegen').Typegen0, context: { errors: [], }, type: 'parallel', states: { connection: { initial: 'disconnected', entry: 'clearErrors', states: { disconnected: { id: 'disconnected', entry: ['clearErrors', 'notifyDisconnect'], }, timedOut: { id: 'timedOut', always: [ { target: 'temporarilyOffline', in: '#offline', }, { target: 'socketCreated', in: '#tabVisible' }, ], on: { PAGE_HIDE: 'pageHidden', PAGE_FREEZE: 'pageHidden', }, }, errorWait: { id: 'errorWait', always: [ { target: 'temporarilyOffline', in: '#offline', }, ], after: { ERROR_WAIT_TIME: 'timedOut', }, on: { PAGE_HIDE: 'pageHidden', PAGE_FREEZE: 'pageHidden', }, }, temporarilyOffline: { entry: ['clearErrors', 'notifyDisconnect'], always: [{ target: 'timedOut', in: '#online' }], on: { PAGE_HIDE: 'pageHidden', PAGE_FREEZE: 'pageHidden', }, }, pageHidden: { entry: 'clearErrors', on: { PAGE_SHOW: 'timedOut', PAGE_RESUME: 'timedOut', }, }, socketCreated: { type: 'parallel', entry: 'openWebSocket', exit: ['cancelPendingRequests', 'closeWebSocket'], states: { open: { initial: 'opening', states: { opening: { always: [{ target: '#timedOut', in: '#mayDisconnect' }], after: { OPEN_TIMEOUT: { actions: 'raiseTimeoutError', }, }, on: { OPENED: { target: 'opened', actions: ['clearErrors', 'notifyReconnect'], }, }, }, opened: { initial: 'pongReceived', states: { pongReceived: { after: { PING_PERIOD: 'pingSent', }, }, pingSent: { invoke: { src: 'pingService', onDone: 'pongReceived', onError: { actions: 'raisePromiseRejectionError', }, }, }, }, }, }, }, idle: { initial: 'active', states: { active: { always: [{ target: 'inactive', in: '#mayDisconnect' }], }, inactive: { always: [{ target: 'active', in: '#tabVisible' }], after: { IDLE_TIMEOUT: '#timedOut', }, }, }, }, }, on: { CONNECT: undefined, ERROR: { target: '#errorWait', actions: 'pushError' }, PAGE_HIDE: 'pageHidden', PAGE_FREEZE: 'pageHidden', }, }, }, on: { CONNECT: '.timedOut', DISCONNECT: '.disconnected', }, }, tab: { initial: 'visibleOrUnknown', states: { visibleOrUnknown: { id: 'tabVisible', on: { TAB_HIDDEN: [ { target: 'hidden.mayDisconnect', in: '#generationIdle' }, { target: 'hidden.keepAlive', in: '#generationRunning' }, ], }, }, hidden: { on: { TAB_VISIBLE: 'visibleOrUnknown', }, initial: 'mayDisconnect', states: { mayDisconnect: { id: 'mayDisconnect', always: { target: 'keepAlive', in: '#generationRunning' }, }, keepAlive: { id: 'keepAlive', always: { target: 'mayDisconnect', in: '#generationIdle' }, }, }, }, }, }, generation: { initial: 'idle', states: { idle: { id: 'generationIdle', on: { GENERATION_STARTED: 'running', }, }, running: { id: 'generationRunning', on: { GENERATION_ENDED: 'idle', }, }, }, }, network: { initial: 'onlineOrUnknown', states: { onlineOrUnknown: { id: 'online', on: { OFFLINE: 'offline', }, }, offline: { id: 'offline', on: { ONLINE: 'onlineOrUnknown', }, }, }, }, }, }, { delays: { IDLE_TIMEOUT: ms('5m'), OPEN_TIMEOUT: ms('10s'), PING_PERIOD: ms('10s'), ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => { const { length } = ERROR_WAIT_TIMES; const index = retryCount < length ? retryCount : length - 1; return ERROR_WAIT_TIMES[index] ?? 0; }, }, actions: { pushError: assign((context, { message }) => ({ ...context, errors: [...context.errors, message], })), clearErrors: assign((context) => ({ ...context, errors: [], })), raiseTimeoutError: raise({ type: 'ERROR', message: 'Open timeout', }), raisePromiseRejectionError: (_context, { data }) => raise({ type: 'ERROR', message: String(data), }), }, }, );