chahinebrini a0d67f33a8 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>
2026-05-14 22:15:57 +02:00

160 lines
4.9 KiB
TypeScript

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();
},
}));