feat(native): realtime debug page + protected-devices array guard
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
d9bb7ef91a
commit
a0d67f33a8
@ -17,6 +17,7 @@ import {
|
|||||||
} from '@expo-google-fonts/nunito';
|
} from '@expo-google-fonts/nunito';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
|
import { useRealtimeDebugStore } from '../stores/realtimeDebug';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useLanguageStore } from '../stores/language';
|
import { useLanguageStore } from '../stores/language';
|
||||||
import { useAppLockStore } from '../stores/appLock';
|
import { useAppLockStore } from '../stores/appLock';
|
||||||
@ -53,6 +54,7 @@ function RootLayoutInner() {
|
|||||||
const initLanguage = useLanguageStore((s) => s.init);
|
const initLanguage = useLanguageStore((s) => s.init);
|
||||||
const initAppLock = useAppLockStore((s) => s.init);
|
const initAppLock = useAppLockStore((s) => s.init);
|
||||||
const appLockReady = useAppLockStore((s) => s.ready);
|
const appLockReady = useAppLockStore((s) => s.ready);
|
||||||
|
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Nunito_400Regular,
|
Nunito_400Regular,
|
||||||
@ -66,6 +68,7 @@ function RootLayoutInner() {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initLanguage();
|
initLanguage();
|
||||||
initAppLock();
|
initAppLock();
|
||||||
|
if (__DEV__) initRealtimeDebug();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -8,6 +16,8 @@ import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
|
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
|
||||||
import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection';
|
import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection';
|
||||||
|
import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
export default function DebugScreen() {
|
export default function DebugScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -98,6 +108,9 @@ export default function DebugScreen() {
|
|||||||
|
|
||||||
<CooldownTestModeToggle />
|
<CooldownTestModeToggle />
|
||||||
|
|
||||||
|
<RealtimeStatusCard />
|
||||||
|
<RealtimeLogCard />
|
||||||
|
|
||||||
<DebugStub
|
<DebugStub
|
||||||
title="LLM-Provider Toggle"
|
title="LLM-Provider Toggle"
|
||||||
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
||||||
@ -118,6 +131,401 @@ export default function DebugScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Realtime Status Card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RealtimeStatusCard() {
|
||||||
|
const colors = useColors();
|
||||||
|
const connectionState = useRealtimeDebugStore((s) => 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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="wifi-outline" size={18} color={colors.textMuted} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
|
||||||
|
Realtime Status
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refreshSnapshot()}
|
||||||
|
hitSlop={8}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={18} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StatusRow
|
||||||
|
label="Connection"
|
||||||
|
value={connectionState}
|
||||||
|
valueColor={stateColor}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Reconnects (since launch)"
|
||||||
|
value={String(reconnectCount)}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Last event"
|
||||||
|
value={lastEventLabel}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Token expires in"
|
||||||
|
value={
|
||||||
|
tokenSecsLeft == null
|
||||||
|
? 'n/a'
|
||||||
|
: tokenSecsLeft < 0
|
||||||
|
? 'EXPIRED'
|
||||||
|
: `${tokenSecsLeft}s`
|
||||||
|
}
|
||||||
|
valueColor={
|
||||||
|
tokenSecsLeft != null && tokenSecsLeft < 120 ? colors.error : undefined
|
||||||
|
}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{channels.length > 0 ? (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active channels ({channels.length})
|
||||||
|
</Text>
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<View
|
||||||
|
key={ch.topic}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{ch.topic}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: stateAccent(ch.state, colors),
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ch.state}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ marginTop: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine aktiven Channels
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueColor?: string;
|
||||||
|
colors: import('../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: valueColor ?? colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="list-outline" size={18} color={colors.textMuted} />
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
|
||||||
|
Realtime Log
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={copyLog} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
|
||||||
|
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={clearLog} hitSlop={8} activeOpacity={0.6}>
|
||||||
|
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
paddingVertical: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Noch keine Events — warte auf Subscription-Aktivität.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
maxHeight: 260,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
>
|
||||||
|
{log.map((entry) => (
|
||||||
|
<LogLine key={entry.id} entry={entry} colors={colors} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 8,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
max 100 Einträge, neueste oben
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
width: 58,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hms}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: isError ? colors.error : isOpen ? colors.success : colors.text,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
flex: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 PLANS: Plan[] = ['free', 'pro', 'legend'];
|
||||||
|
|
||||||
const PLAN_COLOR: Record<Plan, string> = {
|
const PLAN_COLOR: Record<Plan, string> = {
|
||||||
@ -250,6 +658,8 @@ function PlanOverrideToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Cooldown Test Mode ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CooldownTestModeToggle() {
|
function CooldownTestModeToggle() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
@ -317,6 +727,8 @@ function CooldownTestModeToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Debug Stub ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DebugStub({
|
function DebugStub({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|||||||
@ -36,8 +36,8 @@ export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get)
|
|||||||
load: async () => {
|
load: async () => {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
const devices = await apiFetch<ProtectedDevice[]>('/api/devices/protected');
|
const res = await apiFetch<ProtectedDevice[]>('/api/devices/protected');
|
||||||
set({ devices });
|
set({ devices: Array.isArray(res) ? res : [] });
|
||||||
} catch {
|
} catch {
|
||||||
// endpoint might not be ready yet — keep empty state, screen handles it
|
// endpoint might not be ready yet — keep empty state, screen handles it
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
159
apps/rebreak-native/stores/realtimeDebug.ts
Normal file
159
apps/rebreak-native/stores/realtimeDebug.ts
Normal file
@ -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<void>;
|
||||||
|
init: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let logIdSeq = 0;
|
||||||
|
|
||||||
|
export const useRealtimeDebugStore = create<RealtimeDebugState>((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();
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user