import { create } from 'zustand'; import { supabase } from '../lib/supabase'; export type LogEntry = { id: number; ts: number; text: string; }; type RealtimeDebugState = { connectionState: string; reconnectCount: number; lastEventAt: number | null; tokenExpiresAt: number | null; log: LogEntry[]; initialized: boolean; appendLog: (text: string) => void; clearLog: () => void; refreshSnapshot: () => Promise; init: () => void; }; let logIdSeq = 0; export const useRealtimeDebugStore = create((set, get) => ({ connectionState: 'unknown', reconnectCount: 0, lastEventAt: null, tokenExpiresAt: null, log: [], initialized: false, appendLog(text) { const entry: LogEntry = { id: logIdSeq++, ts: Date.now(), text }; set((s) => ({ log: [entry, ...s.log].slice(0, 100), lastEventAt: Date.now(), })); }, clearLog() { set({ log: [] }); }, async refreshSnapshot() { const rt = supabase.realtime as any; // connectionState — Supabase JS v2 exposes this on the internal conn object let connState = 'unknown'; try { // supabase.realtime.connectionState() exists in @supabase/realtime-js >= 2.x if (typeof rt.connectionState === 'function') { connState = String(rt.connectionState()); } else if (rt.conn?.connectionState) { connState = String(rt.conn.connectionState()); } } catch { connState = 'error'; } const { data } = await supabase.auth.getSession(); const tokenExpiresAt = data.session?.expires_at ?? null; set({ connectionState: connState, tokenExpiresAt: tokenExpiresAt ?? null }); }, init() { if (get().initialized) return; set({ initialized: true }); const rt = supabase.realtime as any; // Patch socket-level open/close/error — supabase-js internal conn is a Phoenix socket function hookSocket() { const conn = rt.conn; if (!conn) return false; const origOnOpen = conn.onOpen?.bind(conn); const origOnClose = conn.onClose?.bind(conn); const origOnError = conn.onError?.bind(conn); conn.onOpen(() => { get().appendLog('socket:open'); set({ connectionState: 'OPEN' }); }); conn.onClose((event: { code?: number; reason?: string }) => { const reason = event?.reason ? ` reason=${event.reason}` : ''; get().appendLog(`socket:close code=${event?.code ?? '?'}${reason}`); set((s) => ({ connectionState: 'CLOSED', reconnectCount: s.reconnectCount + 1, })); }); conn.onError((error: unknown) => { get().appendLog(`socket:error ${String(error)}`); }); // Reconnect attempts — Phoenix socket emits reconnectAttempts counter change // We monkey-patch reconnectTimer to detect attempts const origReconnect = conn.reconnect?.bind(conn); if (typeof origReconnect === 'function') { conn.reconnect = function (...args: unknown[]) { set((s) => ({ reconnectCount: s.reconnectCount + 1 })); get().appendLog(`socket:reconnect-attempt #${get().reconnectCount}`); return origReconnect(...args); }; } return true; } // The socket may not yet exist when init() is called (before first subscription). // Poll briefly then give up — subsequent channel subscriptions will still log via channel listener. let attempts = 0; const socketPollInterval = setInterval(() => { attempts++; if (hookSocket() || attempts > 20) { clearInterval(socketPollInterval); } }, 500); // Listen to all channel state changes via supabase.realtime internal channels array // We wrap channel.subscribe to intercept status callbacks // Approach: patch RealtimeChannel.prototype.subscribe so any new channel gets logged try { const RealtimeChannelProto = Object.getPrototypeOf(supabase.channel('__probe__')); supabase.removeAllChannels(); const origChanSubscribe = RealtimeChannelProto.subscribe; if (typeof origChanSubscribe === 'function' && !RealtimeChannelProto.__debugPatched) { RealtimeChannelProto.__debugPatched = true; RealtimeChannelProto.subscribe = function ( callback?: (status: string, err?: unknown) => void, timeout?: number, ) { const channelTopic: string = this.topic ?? '?'; return origChanSubscribe.call( this, (status: string, err?: unknown) => { get().appendLog( `channel:${status.toLowerCase()} ${channelTopic}${err ? ` ${String(err)}` : ''}`, ); if (callback) callback(status, err); }, timeout, ); }; } } catch { // Prototype patching failed — not critical, socket-level hooks still work } // Initial snapshot get().refreshSnapshot(); }, }));