All files / src/services websocket.ts

77.38% Statements 65/84
61.29% Branches 19/31
72.72% Functions 16/22
81.94% Lines 59/72

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152            3x 3x 3x 3x 3x     3x   3x 3x             2x 2x 1x 1x                                   8x 8x 8x   8x 8x 8x 6x 6x           15x   14x     1x         14x 14x 14x   14x 18x 18x 18x 18x 18x 18x 18x 18x     14x 8x 8x     14x         14x           14x         20x 2x 2x   20x 20x 20x 20x 20x       2x               2x 1x 1x   1x             1x         3x 2x             14x 14x    
import { Client, type StompSubscription } from '@stomp/stompjs'
import { ref } from 'vue'
import { getDeviceId } from './device'
 
type MessageCallback = (body: unknown) => void
 
let client: Client | null = null
let deviceId: string | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let attempt = 0
let hasConnectedOnce = false
 
/** Current reconnect attempt number (0 = connected, >0 = retrying). */
export const reconnectAttempt = ref(0)
 
const reconnectListeners: Array<() => void> = []
const connectListeners: Array<() => void> = []
 
/**
 * Register a callback that fires every time the WebSocket reconnects after
 * a drop. Returns an unregister function.
 */
export function onReconnect(cb: () => void): () => void {
  reconnectListeners.push(cb)
  return () => {
    const idx = reconnectListeners.indexOf(cb)
    Eif (idx !== -1) reconnectListeners.splice(idx, 1)
  }
}
 
/**
 * Register a callback that fires on EVERY successful connection — including
 * the very first one. Use this when setup must happen even if the device was
 * offline at startup. Returns an unregister function.
 */
export function onAnyConnect(cb: () => void): () => void {
  connectListeners.push(cb)
  return () => {
    const idx = connectListeners.indexOf(cb)
    if (idx !== -1) connectListeners.splice(idx, 1)
  }
}
 
function scheduleReconnect(): void {
  Iif (reconnectTimer !== null) return
  attempt++
  reconnectAttempt.value = attempt
  // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
  const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30_000)
  console.debug(`[WS] reconnecting in ${delay}ms (attempt ${attempt})`)
  reconnectTimer = setTimeout(() => {
    reconnectTimer = null
    client?.activate()
  }, delay)
}
 
/** Connect to the STOMP broker. Idempotent — safe to call multiple times. */
export async function connectWebSocket(): Promise<void> {
  if (client?.connected) return
 
  deviceId ??= await getDeviceId()
 
  // Client already exists (called again before backoff fires) — just activate
  Iif (client) {
    if (!client.connected) client.activate()
    return
  }
 
  return new Promise<void>((resolve, reject) => {
    client = new Client({ brokerURL: buildBrokerUrl(deviceId!) })
    client.reconnectDelay = 0 // manual exponential backoff via scheduleReconnect
 
    client.onConnect = () => {
      console.debug('[WS] connected')
      const isReconnect = hasConnectedOnce
      hasConnectedOnce = true
      attempt = 0
      reconnectAttempt.value = 0
      connectListeners.forEach(cb => cb())           // fires on every connection
      if (isReconnect) reconnectListeners.forEach(cb => cb()) // fires only on reconnect
      resolve() // no-op if already resolved
    }
 
    client.onDisconnect = () => {
      console.debug('[WS] disconnected')
      scheduleReconnect()
    }
 
    client.onStompError = (frame) => {
      console.error('[WS] STOMP error', frame)
      if (!hasConnectedOnce) reject(new Error('STOMP error'))
    }
 
    client.onWebSocketError = (err) => {
      console.error('[WS] WebSocket error', err)
      if (!hasConnectedOnce) reject(err)
      // After first connect, errors trigger onDisconnect → scheduleReconnect
    }
 
    client.activate()
  })
}
 
export function disconnectWebSocket(): void {
  if (reconnectTimer !== null) {
    clearTimeout(reconnectTimer)
    reconnectTimer = null
  }
  attempt = 0
  reconnectAttempt.value = 0
  hasConnectedOnce = false
  client?.deactivate()
  client = null
}
 
export function isConnected(): boolean {
  return client?.connected ?? false
}
 
/**
 * Subscribe to a STOMP topic.
 * Returns an unsubscribe function.
 */
export function subscribe(topic: string, callback: MessageCallback): () => void {
  if (!client?.connected) {
    console.warn('[WS] subscribe called before connected, topic:', topic)
    return () => {}
  }
  const sub: StompSubscription = client.subscribe(topic, (msg) => {
    try {
      callback(JSON.parse(msg.body))
    } catch {
      callback(msg.body)
    }
  })
  return () => sub.unsubscribe()
}
 
/** Send a message to a STOMP destination. */
export function send(destination: string, body: unknown = ''): void {
  if (!client?.connected) return
  client.publish({
    destination,
    body: typeof body === 'string' ? body : JSON.stringify(body),
  })
}
 
function buildBrokerUrl(deviceId: string): string {
  const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
  return `${protocol}://${location.host}/ws/websocket?deviceId=${deviceId}`
}