From a0d67f33a8f925e343e1bc944d5d2907f678e2fe Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 14 May 2026 22:15:57 +0200 Subject: [PATCH] feat(native): realtime debug page + protected-devices array guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stores/realtimeDebug.ts: neuer DEV-only Zustand-Store mit connection-state, reconnect-counter, token-expiry-countdown, channel-liste, rolling log-buffer (last 100 events). Hookt Phoenix-Socket open/close/reconnect + Channel-subscribe. - _layout.tsx: initRealtimeDebug() im __DEV__-Block beim App-Start. - debug.tsx: zwei neue Cards (RealtimeStatusCard + RealtimeLogCard) mit 1s-Tick-Refresh, Copy + Clear Buttons. Settings-Entry 'Realtime connection (DEV)'. - protectedDevices.ts: Array.isArray-Guard für apiFetch-Response — verhindert TypeError 'devices.filter is not a function' wenn Backend Non-Array zurückgibt. Diagnostik-Tool für Realtime-Disconnect-Bug bei lange eingeloggten Usern. Co-Authored-By: Claude Opus 4.7 --- apps/rebreak-native/app/_layout.tsx | 3 + apps/rebreak-native/app/debug.tsx | 414 +++++++++++++++++- .../rebreak-native/stores/protectedDevices.ts | 4 +- apps/rebreak-native/stores/realtimeDebug.ts | 159 +++++++ 4 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 apps/rebreak-native/stores/realtimeDebug.ts diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 9758d30..357a391 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -17,6 +17,7 @@ import { } from '@expo-google-fonts/nunito'; import { useAuthStore } from '../stores/auth'; import { useThemeStore } from '../stores/theme'; +import { useRealtimeDebugStore } from '../stores/realtimeDebug'; import { useColors } from '../lib/theme'; import { useLanguageStore } from '../stores/language'; import { useAppLockStore } from '../stores/appLock'; @@ -53,6 +54,7 @@ function RootLayoutInner() { const initLanguage = useLanguageStore((s) => s.init); const initAppLock = useAppLockStore((s) => s.init); const appLockReady = useAppLockStore((s) => s.ready); + const initRealtimeDebug = useRealtimeDebugStore((s) => s.init); const colors = useColors(); const [fontsLoaded] = useFonts({ Nunito_400Regular, @@ -66,6 +68,7 @@ function RootLayoutInner() { initTheme(); initLanguage(); initAppLock(); + if (__DEV__) initRealtimeDebug(); }, []); useEffect(() => { diff --git a/apps/rebreak-native/app/debug.tsx b/apps/rebreak-native/app/debug.tsx index f4bd264..405c6cc 100644 --- a/apps/rebreak-native/app/debug.tsx +++ b/apps/rebreak-native/app/debug.tsx @@ -1,5 +1,13 @@ import { useEffect, useState } from 'react'; -import { View, Text, ScrollView, Switch, TouchableOpacity, Alert } from 'react-native'; +import { + View, + Text, + ScrollView, + Switch, + TouchableOpacity, + Alert, + Clipboard, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -8,6 +16,8 @@ import { useMe, invalidateMe, type Plan } from '../hooks/useMe'; import { apiFetch } from '../lib/api'; import { PlanChangeSheet } from '../components/plan/PlanChangeSheet'; import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection'; +import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug'; +import { supabase } from '../lib/supabase'; export default function DebugScreen() { const router = useRouter(); @@ -98,6 +108,9 @@ export default function DebugScreen() { + + + s.connectionState); + const reconnectCount = useRealtimeDebugStore((s) => s.reconnectCount); + const lastEventAt = useRealtimeDebugStore((s) => s.lastEventAt); + const tokenExpiresAt = useRealtimeDebugStore((s) => s.tokenExpiresAt); + const refreshSnapshot = useRealtimeDebugStore((s) => s.refreshSnapshot); + + const [tick, setTick] = useState(0); + const [channels, setChannels] = useState<{ topic: string; state: string }[]>([]); + + // Re-render once per second to keep relative timestamps live + useEffect(() => { + const t = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(t); + }, []); + + // Refresh snapshot + channel list on each tick + useEffect(() => { + refreshSnapshot(); + try { + const rt = supabase.realtime as any; + const raw: { topic?: string; state?: string }[] = rt.channels ?? []; + setChannels( + raw.map((ch) => ({ + topic: ch.topic ?? '?', + state: ch.state ?? '?', + })), + ); + } catch { + setChannels([]); + } + }, [tick, refreshSnapshot]); + + const stateColor = stateAccent(connectionState, colors); + + const tokenSecsLeft = + tokenExpiresAt != null ? Math.round(tokenExpiresAt - Date.now() / 1000) : null; + + const lastEventLabel = lastEventAt + ? relativeSeconds(Date.now() - lastEventAt) + : 'never'; + + return ( + + + + + + + Realtime Status + + refreshSnapshot()} + hitSlop={8} + activeOpacity={0.6} + > + + + + + + + + + + {channels.length > 0 ? ( + + + Active channels ({channels.length}) + + {channels.map((ch) => ( + + + {ch.topic} + + + {ch.state} + + + ))} + + ) : ( + + + Keine aktiven Channels + + + )} + + ); +} + +function StatusRow({ + label, + value, + valueColor, + colors, +}: { + label: string; + value: string; + valueColor?: string; + colors: import('../lib/theme').ColorScheme; +}) { + return ( + + + {label} + + + {value} + + + ); +} + +// ─── Realtime Log Card ───────────────────────────────────────────────────── + +function RealtimeLogCard() { + const colors = useColors(); + const log = useRealtimeDebugStore((s) => s.log); + const clearLog = useRealtimeDebugStore((s) => s.clearLog); + + function copyLog() { + const text = log + .map((e) => `[${new Date(e.ts).toISOString()}] ${e.text}`) + .join('\n'); + Clipboard.setString(text); + Alert.alert('Kopiert', `${log.length} Log-Einträge in Zwischenablage.`); + } + + return ( + + + + + + + Realtime Log + + + + + + + + + + {log.length === 0 ? ( + + Noch keine Events — warte auf Subscription-Aktivität. + + ) : ( + + + {log.map((entry) => ( + + ))} + + + )} + + + max 100 Einträge, neueste oben + + + ); +} + +function LogLine({ + entry, + colors, +}: { + entry: LogEntry; + colors: import('../lib/theme').ColorScheme; +}) { + const time = new Date(entry.ts); + const hms = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`; + const isError = + entry.text.includes('error') || + entry.text.includes('close') || + entry.text.includes('CLOSED'); + const isOpen = entry.text.includes('open') || entry.text.includes('joined'); + + return ( + + + {hms} + + + {entry.text} + + + ); +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function pad(n: number) { + return String(n).padStart(2, '0'); +} + +function relativeSeconds(diffMs: number): string { + const s = Math.round(diffMs / 1000); + if (s < 60) return `vor ${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `vor ${m}m`; + return `vor ${Math.floor(m / 60)}h`; +} + +function stateAccent(state: string, colors: import('../lib/theme').ColorScheme): string { + const upper = state.toUpperCase(); + if (upper === 'OPEN' || upper === 'JOINED' || upper === 'SUBSCRIBED') return colors.success; + if (upper === 'CLOSED' || upper === 'CHANNEL_ERROR' || upper === 'TIMED_OUT') + return colors.error; + if (upper === 'CONNECTING' || upper === 'JOINING') return colors.warning; + return colors.textMuted; +} + +// ─── Plan Override ───────────────────────────────────────────────────────── + const PLANS: Plan[] = ['free', 'pro', 'legend']; const PLAN_COLOR: Record = { @@ -250,6 +658,8 @@ function PlanOverrideToggle({ ); } +// ─── Cooldown Test Mode ──────────────────────────────────────────────────── + function CooldownTestModeToggle() { const colors = useColors(); const [enabled, setEnabled] = useState(false); @@ -317,6 +727,8 @@ function CooldownTestModeToggle() { ); } +// ─── Debug Stub ──────────────────────────────────────────────────────────── + function DebugStub({ title, subtitle, diff --git a/apps/rebreak-native/stores/protectedDevices.ts b/apps/rebreak-native/stores/protectedDevices.ts index bc7a0ee..db96f3b 100644 --- a/apps/rebreak-native/stores/protectedDevices.ts +++ b/apps/rebreak-native/stores/protectedDevices.ts @@ -36,8 +36,8 @@ export const useProtectedDevicesStore = create((set, get) load: async () => { set({ loading: true }); try { - const devices = await apiFetch('/api/devices/protected'); - set({ devices }); + const res = await apiFetch('/api/devices/protected'); + set({ devices: Array.isArray(res) ? res : [] }); } catch { // endpoint might not be ready yet — keep empty state, screen handles it } finally { diff --git a/apps/rebreak-native/stores/realtimeDebug.ts b/apps/rebreak-native/stores/realtimeDebug.ts new file mode 100644 index 0000000..8172a1c --- /dev/null +++ b/apps/rebreak-native/stores/realtimeDebug.ts @@ -0,0 +1,159 @@ +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(); + }, +}));