diff --git a/apps/visualizer/src/lib/graphqlWs.ts b/apps/visualizer/src/lib/graphqlWs.ts new file mode 100644 index 0000000..fa5a126 --- /dev/null +++ b/apps/visualizer/src/lib/graphqlWs.ts @@ -0,0 +1,181 @@ +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; + }, + }; +}