Ctrl K
ComunidadGuías…

src/plugins/socketClient.ts

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type _Vue from 'vue'
import { consola } from 'consola'
import { camelCase, mergeWith } from 'lodash-es'
import type { TypedStore } from '@/store'
import type { SocketError } from '@/util/is-socket-error'

const LOG_PREFIX = '[WEBSOCKET]'

const RETRY_MIN = 1000
const RETRY_MAX = 15_000
const RETRY_JITTER = 0.2
const RETRY_SHOW_AFTER = 5000

const PING_AFTER = 10_000
const DEAD_AFTER = 10_000

const FAST_NOTIFY_KEYS = [
  'motion_report'
] as const

const retryDelay = (attempt: number): number => {
  const base = RETRY_MIN * 2 ** Math.min(attempt, 16)

  return Math.min(RETRY_MAX, Math.round(base * (1 + RETRY_JITTER * (2 * Math.random() - 1))))
}

// A 5xx is never suppressible — a 503 drives klippy recovery in socket/onSocketError.
const shouldSuppressError = (
  error: SocketError,
  suppressError: SuppressError | undefined
): boolean => {
  if (error.code >= 500) {
    return false
  }

  if (typeof suppressError !== 'function') {
    return suppressError === true
  }

  try {
    return suppressError(error)
  } catch (predicateError) {
    consola.error(`${LOG_PREFIX} suppressError predicate threw`, predicateError)

    return false
  }
}

export class WebSocketClient {
  private connection: WebSocket | null = null
  private requests = new Map<number, Request>()
  private requestId = 0
  private store: TypedStore
  private cache: CachedParams | null = null
  private retryCount = 0
  private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  private silenceTimeout: ReturnType<typeof setTimeout> | null = null

  constructor (options: SocketPluginOptions) {
    this.store = options.store
  }

  close () {
    this.clearReconnectTimer()

    this.clearSilenceTimer()

    if (this.connection) {
      this.clearCacheAndRequests()
      this.connection.onopen = null
      this.connection.onmessage = null
      this.connection.onerror = null
      this.connection.onclose = null
      this.connection.close()
    }
  }

  connect () {
    this.clearReconnectTimer()

    this.retryCount = 0

    this.openSocket()
  }

  retryNow () {
    if (
      this.reconnectTimeout !== null ||
      this.store.state.socket.retry !== null
    ) {
      this.connect()
    }
  }

  private openSocket () {
    this.clearCacheAndRequests()

    try {
      const url = this.store.state.config.socketUrl

      if (!url) {
        this.store.typedDispatch('socket/onSetStatus', 'disconnected')
        return
      }

      this.store.typedDispatch('socket/onSetStatus', 'connecting')
      const connection = new WebSocket(url)
      this.connection = connection

      this.armDeadTimer()

      connection.onopen = () => {
        if (this.connection !== connection) return

        this.retryCount = 0
        this.armPingTimer()
        this.store.typedDispatch('socket/onSetStatus', 'identifying')
      }

      connection.onclose = (event) => {
        if (this.connection !== connection) return

        consola.debug(`${LOG_PREFIX} Connection closed:`, event)
        this.handleClose()
      }

      connection.onerror = (event) => {
        if (this.connection !== connection) return

        consola.error(`${LOG_PREFIX} Connection error:`, event)
      }

      connection.onmessage = async (message) => {
        if (this.connection !== connection) return

        this.armPingTimer()

        // Parse the data packet.
        const socketResponse = JSON.parse(message.data) as SocketResponse

        if ('id' in socketResponse) {
          const request = this.requests.get(socketResponse.id)

          this.requests.delete(socketResponse.id)

          // Remove a wait if defined.
          if (request?.wait?.length) {
            this.store.typedCommit('wait/setRemoveWait', request.wait)
          }

          if ('error' in socketResponse) { // Is it in error?
            if (request) {
              Object.defineProperty(socketResponse.error, '__request__', { enumerable: false, value: request })

              if (request.onRejected) {
                request.onRejected(socketResponse.error)
              }
            }

            consola.debug(`${LOG_PREFIX} Response error:`, socketResponse.error)

            if (!shouldSuppressError(socketResponse.error, request?.suppressError)) {
              this.store.typedDispatch('socket/onSocketError', socketResponse.error)
            }

            return
          }

          if (request) {
            // these are specific answers to a request we've made.
            // Build the response, including a non-enumerable ref of the original request.
            const result = typeof socketResponse.result === 'string'
              ? { result: socketResponse.result }
              : socketResponse.result

            Object.defineProperty(result, '__request__', { enumerable: false, value: request })

            consola.debug(`${LOG_PREFIX} Response:`, result)

            if (request.dispatch) {
              this.store.dispatch(request.dispatch, result)
            }

            if (request.onFulfilled) {
              // Unchecked: trusted to match the method's result
              request.onFulfilled(socketResponse.result)
            }
          }

          return
        }

        // These are socket notifications (i.e., no specific request was made..)
        if (socketResponse.method !== 'notify_status_update') {
          // Normally, we let notifications through with no cache...
          this.store.dispatch(`socket/${camelCase(socketResponse.method)}`, socketResponse.params?.[0])
        } else {
          // ...However, status notifications come through thick and fast,
          // so we cache these and send them through every second.
          const [params, eventtime] = socketResponse.params

          // If any of these properties exist, bypass the cache and send immediately
          for (const key of FAST_NOTIFY_KEYS) {
            if (key in params) {
              this.store.typedDispatch('printer/onFastNotifyStatusUpdate', { key, payload: params[key] }, { root: true })
              delete params[key]
            }
          }

          const timestamp = eventtime ? eventtime * 1000 : Date.now()

          this.cache = !this.cache
            ? { timestamp, params }
            : { timestamp: this.cache.timestamp, params: mergeWith(this.cache.params, params, (dest, src) => Array.isArray(dest) ? src : undefined) }

          // If there's a second or more difference, flush the cache.
          if (timestamp - this.cache.timestamp >= 1000) {
            this.store.typedDispatch('socket/notifyStatusUpdate', this.cache.params)
            this.cache = { timestamp, params: {} }
          }
        }
      }
    } catch (error: unknown) {
      consola.error(`${LOG_PREFIX} Failed to open WebSocket:`, error)
      this.handleClose()
    }
  }

  private handleClose () {
    // If the socket is already marked disconnected, there is nothing left to do.
    if (this.store.state.socket.status === 'disconnected') return

    this.clearSilenceTimer()
    this.clearCacheAndRequests()
    this.connection = null

    this.store.typedDispatch('socket/onSetStatus', 'connecting')

    const delay = retryDelay(this.retryCount)

    this.retryCount += 1

    if (delay >= RETRY_SHOW_AFTER) {
      this.store.typedCommit('socket/setRetry', { at: Date.now() + delay })
    }

    this.reconnectTimeout = setTimeout(() => {
      this.clearReconnectTimer()

      if (
        document.hidden ||
        !navigator.onLine
      ) {
        this.store.typedCommit('socket/setRetry', { held: true })
      } else {
        this.openSocket()
      }
    }, delay)
  }

  private clearReconnectTimer () {
    if (this.reconnectTimeout !== null) {
      clearTimeout(this.reconnectTimeout)
      this.reconnectTimeout = null
    }

    if (this.store.state.socket.retry !== null) {
      this.store.typedCommit('socket/setRetry', null)
    }
  }

  private armPingTimer () {
    this.clearSilenceTimer()

    this.silenceTimeout = setTimeout(() => {
      consola.debug(`${LOG_PREFIX} No message for ${PING_AFTER} ms`)

      this.emit('server.info', { suppressError: true })
        .catch(() => {})

      this.armDeadTimer()

      this.store.typedCommit('socket/setStalled', true)
    }, PING_AFTER)
  }

  private armDeadTimer () {
    this.clearSilenceTimer()

    this.silenceTimeout = setTimeout(() => {
      const connection = this.connection

      consola.debug(`${LOG_PREFIX} No response for ${DEAD_AFTER} ms, replacing the connection`)

      this.handleClose()

      connection?.close()
    }, DEAD_AFTER)
  }

  private clearSilenceTimer () {
    if (this.silenceTimeout !== null) {
      clearTimeout(this.silenceTimeout)
      this.silenceTimeout = null
    }

    if (this.store.state.socket.stalled) {
      this.store.typedCommit('socket/setStalled', false)
    }
  }

  /**
   * Sends data TO the socket
   */
  emit<
    M extends Moonraker.Method,
    R extends Moonraker.MethodResult<M> = Moonraker.MethodResult<M>
  > (method: M, options: EmitOptions<M>) {
    return new Promise<R>((resolve, reject) => {
      try {
        const { wait, params, dispatch, suppressError } = options

        // Any non-'disconnected' state is eligible to emit; physical readiness
        // is enforced by the readyState check below.
        if (this.store.state.socket.status === 'disconnected') {
          consola.debug(`${LOG_PREFIX} Socket emit denied, disconnected:`, method, options)

          throw new Error('Socket is disconnected')
        }

        if (this.connection?.readyState === WebSocket.OPEN) {
          this.requestId = (this.requestId + 1) % 90_000

          const id = this.requestId + 10_000

          const packet: SocketRequest<M> = {
            id,
            method,
            jsonrpc: '2.0'
          }

          if (params) {
            packet.params = params
          }

          const request: Request<EmitOptions<M>['params'], R> = {
            id,
            dispatch,
            params,
            wait,
            suppressError,
            onFulfilled: resolve,
            onRejected: reject
          }

          if (wait) {
            this.store.typedDispatch('wait/addWait', wait)
          }

          this.requests.set(id, request)
          this.connection.send(JSON.stringify(packet))
        } else {
          consola.debug(`${LOG_PREFIX} Not ready, or closed.`, method, options, this.connection?.readyState)

          throw new Error('Socket is not ready or closed')
        }
      } catch (error) {
        reject(error)
      }
    })
  }

  clearCacheAndRequests () {
    this.cache = null

    for (const request of this.requests.values()) {
      if (request.onRejected) {
        request.onRejected(new Error('Socket disconnected'))
      }
    }

    this.requests.clear()
  }
}

export const SocketPlugin = {
  install (Vue: typeof _Vue, options?: SocketPluginOptions) {
    if (options?.store == null) {
      throw new Error('options required')
    }

    const socket = new WebSocketClient(options)
    Vue.prototype.$socket = socket
    Vue.$socket = socket

    window.addEventListener('online', () => socket.retryNow())

    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        socket.retryNow()
      }
    })
  }
}

declare module 'vue/types/vue' {
  interface Vue {
    $socket: WebSocketClient;
  }

  interface VueConstructor {
    $socket: WebSocketClient;
  }
}

interface SocketPluginOptions {
  store: TypedStore;
}

export type SuppressError = boolean | ((error: SocketError) => boolean)

export interface RequestOptions {
  dispatch?: string;
  wait?: string;
  suppressError?: SuppressError;
}

// skipLibCheck hides .d.ts errors, so validate the Methods shape here
type AssertMethods<T extends Record<keyof T, { params: unknown, result: unknown }>> = T
export type CheckedMethods = AssertMethods<Moonraker.Methods>

type AssertNotifications<T extends Record<keyof T, [unknown, ...unknown[]] | undefined>> = T
export type CheckedNotifications = AssertNotifications<Moonraker.Notifications>

type RequestParams<M extends Moonraker.Method> = object extends Moonraker.MethodParams<M>
  ? Moonraker.MethodParams<M> | undefined
  : Moonraker.MethodParams<M>

export type EmitOptions<M extends Moonraker.Method> = RequestOptions & (
  undefined extends RequestParams<M>
    ? { params?: RequestParams<M> }
    : { params: RequestParams<M> }
)

interface Request<P = unknown, R = unknown> {
  id: number;
  dispatch?: string;
  params: P;
  wait?: string;
  suppressError?: SuppressError;
  // Method syntax (bivariant) so any Request<P, R> fits in `requests`
  onFulfilled (value: R): void;
  onRejected: (reason?: unknown) => void;
}

// String results are dispatched as `{ result }`
type DispatchedResult<R> = R extends string ? { result: R } : R

export type ObjectWithRequest<M extends Moonraker.Method> = DispatchedResult<Moonraker.MethodResult<M>> & {
  __request__: Request<RequestParams<M>, Moonraker.MethodResult<M>>
}

interface SocketRequest<M extends Moonraker.Method> {
  jsonrpc: '2.0';
  id: number;
  method: M;
  params?: RequestParams<M>;
}

interface SocketResponseBase {
  jsonrpc: string; // always available
}

interface SocketApiResponse extends SocketResponseBase {
  id: number;
  result: string | Record<string, any>;
}

interface SocketApiErrorResponse extends SocketResponseBase {
  id: number;
  error: SocketError;
}

type SocketNotificationResponse = {
  [N in Moonraker.Notification]: SocketResponseBase & {
    method: N;
    params: Moonraker.Notifications[N];
  }
}[Moonraker.Notification]

type SocketResponse = SocketApiResponse | SocketApiErrorResponse | SocketNotificationResponse

interface CachedParams {
  timestamp: number;
  params: Record<string, any>;
}