- 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>
160 lines
4.9 KiB
TypeScript
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();
|
|
},
|
|
}));
|