type HeadersMap = Record; type SubscribeParams = { query: string; variables?: Record; onData: (data: T) => void; onError?: (err: string) => void; onStatus?: (s: { connected: boolean }) => void; }; function envString(name: string): string | undefined { const v = (import.meta as any).env?.[name]; const s = v == null ? '' : String(v).trim(); return s ? s : undefined; } function resolveGraphqlHttpUrl(): string { return envString('VITE_HASURA_URL') || '/graphql'; } function resolveGraphqlWsUrl(): string { const explicit = envString('VITE_HASURA_WS_URL'); if (explicit) { if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit; if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`; if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const path = explicit.startsWith('/') ? explicit : `/${explicit}`; return `${proto}//${host}${path}`; } const httpUrl = resolveGraphqlHttpUrl(); if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl; if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`; if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`; return `${proto}//${host}${path}`; } function resolveAuthHeaders(): HeadersMap | undefined { const token = envString('VITE_HASURA_AUTH_TOKEN'); if (token) return { authorization: `Bearer ${token}` }; const secret = envString('VITE_HASURA_ADMIN_SECRET'); if (secret) return { 'x-hasura-admin-secret': secret }; return undefined; } type WsMessage = | { type: 'connection_ack' | 'ka' | 'complete' } | { type: 'connection_error'; payload?: any } | { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } } | { type: 'error'; id: string; payload?: any }; export type SubscriptionHandle = { unsubscribe: () => void; }; export function subscribeGraphqlWs({ query, variables, onData, onError, onStatus }: SubscribeParams): SubscriptionHandle { const wsUrl = resolveGraphqlWsUrl(); const headers = resolveAuthHeaders(); let ws: WebSocket | null = null; let closed = false; let started = false; let reconnectTimer: number | null = null; const subId = '1'; const emitError = (e: unknown) => { const msg = typeof e === 'string' ? e : String((e as any)?.message || e); onError?.(msg); }; const setConnected = (connected: boolean) => onStatus?.({ connected }); const start = () => { if (!ws || started) return; started = true; ws.send( JSON.stringify({ id: subId, type: 'start', payload: { query, variables: variables ?? {} }, }) ); }; const connect = () => { if (closed) return; started = false; try { ws = new WebSocket(wsUrl, 'graphql-ws'); } catch (e) { emitError(e); reconnectTimer = window.setTimeout(connect, 1000); return; } ws.onopen = () => { setConnected(true); const payload = headers ? { headers } : {}; ws?.send(JSON.stringify({ type: 'connection_init', payload })); }; ws.onmessage = (ev) => { let msg: WsMessage; try { msg = JSON.parse(String(ev.data)); } catch (e) { emitError(e); return; } if (msg.type === 'connection_ack') { start(); return; } if (msg.type === 'connection_error') { emitError(msg.payload || 'connection_error'); return; } if (msg.type === 'ka' || msg.type === 'complete') return; if (msg.type === 'error') { emitError(msg.payload || 'subscription_error'); return; } if (msg.type === 'data') { const errors = msg.payload?.errors; if (Array.isArray(errors) && errors.length) { emitError(errors.map((e) => e.message).join(' | ')); return; } if (msg.payload?.data != null) onData(msg.payload.data as T); } }; ws.onerror = () => { setConnected(false); }; ws.onclose = () => { setConnected(false); if (closed) return; reconnectTimer = window.setTimeout(connect, 1000); }; }; connect(); return { unsubscribe: () => { closed = true; setConnected(false); if (reconnectTimer != null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } if (!ws) return; try { ws.send(JSON.stringify({ id: subId, type: 'stop' })); ws.send(JSON.stringify({ type: 'connection_terminate' })); } catch { // ignore } try { ws.close(); } catch { // ignore } ws = null; }, }; }