dd89b3d934
CI / test (push) Has been cancelled
Mercure topic identifiers updated in lockstep across PHP publisher + TS subscriber (and their tests). Help-page setup instructions now point to wevisto.com. Traefik already serves both hosts; this aligns the in-app references with the public brand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
import { onUnmounted, watch } from 'vue'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useDevicesStore } from '@/stores/devices'
|
|
import type { Device } from '@/types'
|
|
|
|
const TOPIC_PREFIX = 'https://wevisto.com/devices/'
|
|
|
|
declare global {
|
|
interface Window {
|
|
__PF_MERCURE_URL__?: string
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open one EventSource per device topic and merge inbound updates into the
|
|
* devices store. Reconnect on close (Mercure usually proxies through Traefik
|
|
* which can drop idle connections). Cleans up on unmount.
|
|
*
|
|
* The subscription set is reactive — when the device list changes (a new
|
|
* frame is provisioned, or one is removed), connections are added/dropped
|
|
* to match. The server publishes the same JSON shape that GET /api/devices
|
|
* returns, so updates are a straight splat into the existing store entry.
|
|
*/
|
|
export function useDeviceMercure() {
|
|
const devicesStore = useDevicesStore()
|
|
const { devices } = storeToRefs(devicesStore)
|
|
|
|
// deviceId → EventSource. Tracked outside Vue's reactivity to avoid
|
|
// accidentally proxying the native EventSource and breaking it.
|
|
const sources = new Map<number, EventSource>()
|
|
// deviceId → reconnect timer handle.
|
|
const reconnectTimers = new Map<number, number>()
|
|
|
|
const baseUrl = window.__PF_MERCURE_URL__
|
|
if (!baseUrl) {
|
|
// No URL configured (dev without a hub, or SSR-render fallback) — quietly
|
|
// no-op rather than throwing. Polling-on-visibility-change is still wired
|
|
// up in HomeView, so the UI keeps working.
|
|
return { connectedCount: () => 0 }
|
|
}
|
|
|
|
function open(deviceId: number) {
|
|
if (sources.has(deviceId)) return
|
|
try {
|
|
const url = new URL(baseUrl!)
|
|
url.searchParams.append('topic', TOPIC_PREFIX + deviceId)
|
|
const es = new EventSource(url.toString(), { withCredentials: true })
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data) as Device | { id: number; deleted: true }
|
|
// Deletion sentinel: server sends {id, deleted: true} when the
|
|
// owner removed the frame. Splice out + close our subscription.
|
|
if ('deleted' in payload && payload.deleted === true) {
|
|
devices.value = devices.value.filter(d => d.id !== payload.id)
|
|
close(payload.id)
|
|
return
|
|
}
|
|
const updated = payload as Device
|
|
const idx = devices.value.findIndex(d => d.id === updated.id)
|
|
if (idx !== -1) {
|
|
// Splice replacement so Vue's reactivity tracks the swap.
|
|
devices.value[idx] = updated
|
|
}
|
|
} catch (e) {
|
|
console.warn('[mercure] parse error', e)
|
|
}
|
|
}
|
|
|
|
es.onerror = () => {
|
|
// Mercure / Traefik will sometimes close idle connections; reopen
|
|
// after a short delay rather than spinning. CLOSED is the only
|
|
// terminal state that needs a manual reconnect.
|
|
if (es.readyState === EventSource.CLOSED) {
|
|
close(deviceId)
|
|
const handle = window.setTimeout(() => {
|
|
reconnectTimers.delete(deviceId)
|
|
open(deviceId)
|
|
}, 5000)
|
|
reconnectTimers.set(deviceId, handle)
|
|
}
|
|
}
|
|
|
|
sources.set(deviceId, es)
|
|
} catch (e) {
|
|
console.warn('[mercure] open failed for device ' + deviceId, e)
|
|
}
|
|
}
|
|
|
|
function close(deviceId: number) {
|
|
const es = sources.get(deviceId)
|
|
if (es) {
|
|
es.close()
|
|
sources.delete(deviceId)
|
|
}
|
|
const timer = reconnectTimers.get(deviceId)
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
reconnectTimers.delete(deviceId)
|
|
}
|
|
}
|
|
|
|
// Sync subscriptions to the current device list.
|
|
watch(
|
|
devices,
|
|
(list) => {
|
|
const wantIds = new Set(list.map(d => d.id))
|
|
// Open new ones.
|
|
for (const id of wantIds) if (!sources.has(id)) open(id)
|
|
// Close stale ones.
|
|
for (const id of [...sources.keys()]) if (!wantIds.has(id)) close(id)
|
|
},
|
|
{ immediate: true, deep: false },
|
|
)
|
|
|
|
onUnmounted(() => {
|
|
for (const id of [...sources.keys()]) close(id)
|
|
})
|
|
|
|
return { connectedCount: () => sources.size }
|
|
}
|