import { computed, onMounted, onScopeDispose, readonly, ref, toValue, watch, type MaybeRefOrGetter, type Ref, } from 'vue' import { useApi } from './useApi' const PRESENCE_CLIENT_ID_KEY = 'mint:presence:client-id' const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000 let pageFallbackClientId: string | null = null export interface PresenceHeartbeatPayload { client_id: string scope: string } export interface PresenceHeartbeatResponse { frontend_revision?: string | null } interface PresenceDocument { readonly visibilityState: DocumentVisibilityState addEventListener(type: 'visibilitychange', listener: EventListener): void removeEventListener(type: 'visibilitychange', listener: EventListener): void } interface PresenceStorage { getItem(key: string): string | null setItem(key: string, value: string): void } export interface PresenceHeartbeatOptions { /** Platform, plugin id, or plugin name reported for this browser tab. */ scope: MaybeRefOrGetter /** Disable all traffic, including for standalone plugins. */ enabled?: MaybeRefOrGetter /** Heartbeat interval. Defaults to 30 seconds. */ intervalMs?: number /** Test or host storage seam. Defaults to sessionStorage. */ storage?: PresenceStorage | null /** Test or host document seam. Defaults to the current document. */ document?: PresenceDocument | null /** Test seam for generating the per-tab id. */ createClientId?: () => string /** Test or custom transport seam. Defaults to authenticated POST /api/presence/heartbeat. */ send?: (payload: PresenceHeartbeatPayload) => Promise | unknown /** Observe the authenticated response without adding another poller. */ onResponse?: (response: PresenceHeartbeatResponse | unknown) => void /** Test timing seam. Defaults to the browser scheduler. */ setInterval?: (callback: () => void, intervalMs: number) => unknown /** Test timing seam. Defaults to the browser scheduler. */ clearInterval?: (intervalId: unknown) => void } export interface UsePresenceHeartbeatReturn { clientId: string isRunning: Readonly> sendNow: () => Promise } function defaultClientId(): string { if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID() } return `presence-${Date.now()}-${Math.random().toString(36).slice(2)}` } function defaultStorage(): PresenceStorage | null { try { return globalThis.sessionStorage ?? null } catch { return null } } function defaultDocument(): PresenceDocument | null { return typeof globalThis.document === 'undefined' ? null : globalThis.document } /** Return the stable id shared by presence callers in the current browser tab. */ export function getPresenceClientId( storage: PresenceStorage | null = defaultStorage(), createClientId: () => string = defaultClientId, ): string { try { const stored = storage?.getItem(PRESENCE_CLIENT_ID_KEY) if (stored) return stored } catch { // Keep the tab usable when storage is blocked. } const nextClientId = createClientId() try { if (storage) { storage.setItem(PRESENCE_CLIENT_ID_KEY, nextClientId) return nextClientId } } catch { // Fall through to the page-local fallback when storage is blocked. } if (!pageFallbackClientId) pageFallbackClientId = nextClientId return pageFallbackClientId } /** Report one visible integrated MINT browser tab to the platform presence tracker. */ export function usePresenceHeartbeat( options: PresenceHeartbeatOptions, ): UsePresenceHeartbeatReturn { const presenceDocument = options.document === undefined ? defaultDocument() : options.document const clientId = getPresenceClientId( options.storage === undefined ? defaultStorage() : options.storage, options.createClientId ?? defaultClientId, ) const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS const scheduleInterval = options.setInterval ?? ((callback: () => void, delayMs: number) => ( globalThis.setInterval(callback, delayMs) )) const cancelInterval = options.clearInterval ?? ((intervalId: unknown) => { globalThis.clearInterval(intervalId as ReturnType) }) if (intervalMs <= 0) throw new Error('Presence heartbeat interval must be positive') const api = options.send ? null : useApi({ baseUrl: '/api' }) const send = options.send ?? ((payload: PresenceHeartbeatPayload) => ( api!.post('/presence/heartbeat', payload) )) const enabled = computed(() => toValue(options.enabled ?? true)) const scope = computed(() => toValue(options.scope)?.trim() ?? '') const isRunning = ref(false) let intervalId: unknown | null = null let sending = false let mounted = false function isVisible(): boolean { return presenceDocument?.visibilityState !== 'hidden' } function shouldRun(): boolean { return mounted && enabled.value && Boolean(scope.value) && isVisible() } async function sendNow(): Promise { if (!shouldRun() || sending) return sending = true try { const response = await send({ client_id: clientId, scope: scope.value }) options.onResponse?.(response) } catch { // Presence is advisory and must never break plugin or platform UI. } finally { sending = false } } function stop(): void { if (intervalId !== null) cancelInterval(intervalId) intervalId = null isRunning.value = false } function start(): void { stop() if (!shouldRun()) return isRunning.value = true void sendNow() intervalId = scheduleInterval(() => void sendNow(), intervalMs) } function handleVisibilityChange(): void { start() } watch([enabled, scope], start) onMounted(() => { mounted = true presenceDocument?.addEventListener('visibilitychange', handleVisibilityChange) start() }) onScopeDispose(() => { mounted = false presenceDocument?.removeEventListener('visibilitychange', handleVisibilityChange) stop() }) return { clientId, isRunning: readonly(isRunning), sendNow, } }